权限系统
Agent 系统作者:Claude Code 的权限系统是 7 种 mode × 8 种规则来源 × 11 种决策理由 × 10 种 hook 事件——拆开每一层的源码实现。
这一章的定位
权限系统回答的是**“能不能做”**——mode / rules / hooks / classifier 的信任决策层。
和 执行环境 正交——后者回答”在哪里做”(worktree / CCR / Seatbelt / bwrap 等物理隔离)。两个问题分章讲。
大多数 agent 产品的权限设计两个极端:全开(Devin 早期、Aider 默认)或 全问(疲劳 → 全部允许)。
Claude Code 走的第三条路:把权限做成一个多层的决策系统,在源码里是7 种 mode × 8 种规则来源 × 11 种决策理由 × 10 种 hook 事件的交叉产物。这一章把这个系统拆开。
Permission Modes:7 种,不是 4 种
源码里完整的 permission mode 集合(types/permissions.ts):
export const EXTERNAL_PERMISSION_MODES = [
'acceptEdits',
'bypassPermissions',
'default',
'dontAsk',
'plan',
] as const
export type InternalPermissionMode = ExternalPermissionMode | 'auto' | 'bubble'
对外 5 种 + 对内扩展 2 种 = 7 种模式。下面是完整的语义(源码 utils/permissions/PermissionMode.ts 的 PERMISSION_MODE_CONFIG):
| 模式 | 别名 | 语义 | 风险 |
|---|---|---|---|
default | Default | 日常基线——读自动、写询问、破坏性询问 | 低 |
plan | Plan Mode | 只允许读 + TaskCreate,禁止一切写 | 零 |
acceptEdits | Accept edits | 读写自动,破坏性询问 | 中 |
bypassPermissions | Bypass | 全部自动——但 deny-list / hook 仍生效 | 高 |
dontAsk | Don’t Ask | 类似 bypass,语义上明确”不问我” | 高 |
auto | Auto mode | ant-only,feature-flagged TRANSCRIPT_CLASSIFIER——用 LLM 分类器判断每条命令 | 可变(classifier 控) |
bubble | —— | 内部模式,不对外暴露 | —— |
几个容易忽略的点:
dontAskvsbypassPermissions不完全等价——它们的颜色 / UI 呈现不同,source 里定义的都是color: 'error',但这是两条独立的模式常量。用户用dontAsk表达意图是”我关掉所有询问”,bypassPermissions更像”全权允许”。两者的底层行为接近,但产品语义有别auto模式启用了 bash classifier:每条 bash 命令跑一个 YOLO classifier(源码types/permissions.ts第 346-397 行的YoloClassifierResult)。分类器有 2 阶段(fast / thinking),完整遥测 input/output tokens、cache stats、request id——这是用 LLM 判断工具调用是否安全的实际实现- Plan mode 的符号是
PAUSE_ICON——UI 层面把它可视化为暂停状态,清晰告诉用户”我在看但不动” acceptEdits/bypassPermissions/dontAsk的符号都是⏵⏵——fast-forward 含义,跟视频播放器快进符号一致
给自研 agent 的启示:权限 mode 不只是 config,是 UI 状态。用户切换 mode 时应该有视觉反馈(颜色、符号、状态栏),否则他们很快会忘记自己处于什么信任级别。
8 种规则来源
PermissionRuleSource (types/permissions.ts) 定义了规则来源的严格枚举:
export type PermissionRuleSource =
| 'userSettings' // ~/.claude/settings.json
| 'projectSettings' // <repo>/.claude/settings.json(进 git)
| 'localSettings' // <repo>/.claude/settings.local.json(不进 git)
| 'flagSettings' // CLI --allowedTools / --disallowedTools
| 'policySettings' // 企业 policy(Managed settings)
| 'cliArg' // 命令行参数
| 'command' // /permissions 等命令追加的
| 'session' // 本 session 内 "always allow" 选的
分层的含义:
- userSettings / projectSettings / localSettings——跟记忆系统对齐的三层(见 记忆系统)
- policySettings——企业 / IT 部门的强制规则。policy 可以覆盖所有其他 source,用于合规
- flagSettings / cliArg——命令行传入的 one-off 规则
- session——用户在对话中点过 “always allow X” 追加的规则,会话结束就丢
- command——通过
/permissionsslash command 交互追加的
每条 PermissionRule 携带自己的 source,规则冲突时可以溯源——audit 时能回答”这条 allow 是从哪来的”。
给自研 agent 的启示:权限规则不要只存最终值,存 (value, source) 元组。安全事故 / 合规审计的时候你会需要溯源。
11 种决策理由
一条权限决策是 allow / ask / deny 之一,但为什么是这个决策——PermissionDecisionReason 有严格的 11 种分类(types/permissions.ts 第 271-324 行):
export type PermissionDecisionReason =
| { type: 'rule'; rule: PermissionRule }
| { type: 'mode'; mode: PermissionMode }
| { type: 'subcommandResults'; reasons: Map<string, PermissionResult> }
| { type: 'permissionPromptTool'; permissionPromptToolName: string; toolResult: unknown }
| { type: 'hook'; hookName: string; hookSource?: string; reason?: string }
| { type: 'asyncAgent'; reason: string }
| { type: 'sandboxOverride'; reason: 'excludedCommand' | 'dangerouslyDisableSandbox' }
| { type: 'classifier'; classifier: string; reason: string }
| { type: 'workingDir'; reason: string }
| { type: 'safetyCheck'; reason: string; classifierApprovable: boolean }
| { type: 'other'; reason: string }
每种类型表达不同的决策源头:
| 类型 | 含义 |
|---|---|
rule | 匹配了某条显式规则(allow / ask / deny) |
mode | 当前 permission mode 的默认行为 |
subcommandResults | 复合命令(cmd1 && cmd2)——每个子命令单独判断,整体合取 |
permissionPromptTool | 外部 “permission prompt tool” 返回了决策 |
hook | PreToolUse hook 返回 permissionDecision |
asyncAgent | 异步 agent 分类器判断 |
sandboxOverride | 命令被标为”无法 sandbox”或用户 --dangerously-disable-sandbox |
classifier | YOLO bash classifier 判断 |
workingDir | 命令要访问的路径不在 allowed dirs 内 |
safetyCheck | 静态安全检查(敏感文件、Windows 路径 bypass、跨机器 bridge 等) |
other | 兜底 |
为什么这么多类型:审计 + 解释性。当用户问”为什么这条命令被 deny 了”,UI 能展开完整的决策路径——不是一句”blocked”,是”因为 policySettings 里的 deny 规则 X 匹配了”。
给自研 agent 的启示:决策产物(allow / deny)和决策理由(11 种之一)要分离。前者是二元的,后者让你能解释、追查、重跑。
Hook 系统:10 种事件
源码里有10 种 hook 事件,不是我之前写的 5 种(utils/hooks.ts 里的 hook event name 枚举):
| 事件 | 触发时机 |
|---|---|
PreToolUse | 工具调用前 |
PostToolUse | 工具成功返回后 |
PostToolUseFailure | 工具失败返回后(新增) |
UserPromptSubmit | 用户发送消息时 |
Stop | 每轮结束时 |
StopFailure | 每轮异常结束 |
SubagentStop | 子 agent 结束时 |
PreCompact | 压缩前(见 压缩) |
PostCompact | 压缩后 |
WorktreeCreate | 创建 worktree 时 |
新增的事件值得注意:
PostToolUseFailure:工具失败时单独触发的 hook——让你可以对失败专门做处理(比如记 Sentry、发告警),而不和成功路径混在一起StopFailure/SubagentStop:区分了主 agent 和子 agent 的 stop 事件WorktreeCreate:worktree 创建时触发——可以在这里跑 setup script(拉 deps、准备 sandbox 等)
Hook 输出的完整 JSON schema
源码 utils/hooks.ts 第 415-444 行暴露了 hook 返回的完整 schema:
{
continue: boolean, // 是否继续(false → 中断 agent 循环)
suppressOutput: boolean, // 是否抑制 hook 的 stdout
stopReason: string, // 中断原因(显示给用户)
decision: '"approve" | "block"', // 简化决策
reason: string, // 决策原因
systemMessage: string, // 注入给 agent 的 system message
permissionDecision: '"allow" | "deny" | "ask"',
hookSpecificOutput: {
// 每个 event 的专属字段:
'PreToolUse': { hookEventName, permissionDecision, permissionDecisionReason, updatedInput },
'UserPromptSubmit': { hookEventName, additionalContext },
'PostToolUse': { hookEventName, additionalContext },
}
}
几个值得深挖的字段:
updatedInput(仅 PreToolUse):hook 可以修改工具调用的参数。例子:改写 Bash 命令在运行前 filter 掉敏感 output、改写 Write 内容加 header、改写 Read 的路径指向 sandbox 版本。这是非常强大的能力——hook 不只是 allow/deny,是可拦截可修改additionalContext(UserPromptSubmit / PostToolUse):hook 可以注入额外上下文到 prompt / conversation 里。例子:“用户提交了 prompt 后追加当前 CI 状态”、“工具成功后追加相关文档”continue: false:hook 可以打断整个 agent 循环,不只是拒绝当前操作systemMessage:hook 可以向 agent 注入 system-level 指令——这是 hook 能力的最强表现,等于临时扩展 system prompt
Hook 超时:TOOL_HOOK_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000 —— 10 分钟。这个长度暗示 hook 是一等公民,不是快速拦截器。它可以跑 lint、跑 test、发邮件、做复杂分析。
Hook 非 JSON 输出的处理
Hook 输出可以是 JSON 也可以是 plain text(源码 parseHookOutput):
- JSON:按上面的 schema 解析,走结构化路径
- Plain text(不以
{开头):原文注入为additionalContext
这降低了写 hook 的门槛——简单的 hook 直接 echo 一行到 stdout 就行。
给自研 agent 的启示:hook 接口要支持三个层次——简单的 plain text → 中等的 JSON decision → 复杂的 updatedInput + systemMessage。门槛低、上限高,用户按需升级。
进程级沙箱属于执行环境层
本章不讲进程级沙箱实现(macOS Seatbelt / Linux bwrap+seccomp / Windows 无沙箱)——那是 执行环境 的话题。本章只讲权限决策层。
两层的交互:权限层决定”要不要做”,沙箱层决定”做的时候能不能踩到敏感资源”。bypassPermissions mode 把权限层完全打开,但沙箱层仍然生效——bwrap 不会因为 bypassPermissions 就让你写 /etc/passwd。这是 四层防线 讨论的核心。
Bash DANGEROUS_PATTERNS:auto mode 的 allowlist 保护
utils/permissions/dangerousPatterns.ts 定义了一个危险 bash 前缀清单,用于 auto mode 启动时自动剥离过于宽松的 allow 规则:
export const CROSS_PLATFORM_CODE_EXEC = [
// 解释器
'python', 'python3', 'python2', 'node', 'deno', 'tsx',
'ruby', 'perl', 'php', 'lua',
// Package runners
'npx', 'bunx', 'npm run', 'yarn run', 'pnpm run', 'bun run',
// Shells
'bash', 'sh',
// 远程
'ssh',
] as const
export const DANGEROUS_BASH_PATTERNS: readonly string[] = [
...CROSS_PLATFORM_CODE_EXEC,
'zsh', 'fish', 'eval', 'exec', 'env', 'xargs', 'sudo',
// Ant-only 扩展
...(process.env.USER_TYPE === 'ant' ? [
'fa run', 'coo', // Ant 内部工具
'gh', 'gh api', 'curl', 'wget', // 网络 / 外泄
'git', // git config core.sshCommand = 任意代码
'kubectl', 'aws', 'gcloud', 'gsutil', // 云资源写
] : []),
]
设计意图(源码注释):
An allow rule like
Bash(python:*)orPowerShell(node:*)lets the model run arbitrary code via that interpreter, bypassing the auto-mode classifier.
翻译:如果用户写了 Bash(python:*) 这样的 allow 规则,模型就可以通过 python 跑任意代码——绕过了 bash classifier 的审查。这种”表面限制但实际放任”的规则在 auto mode 入口被显式剥离。
ant-only 的扩展清单特别值得注意:git 也在里面——因为 git config core.sshCommand 能跑任意代码,git 的 allow prefix 不安全。这是从对抗者视角出发的 pattern 清单,不是信任 command name 字面意思。
给自研 agent 的启示:allowlist 不能只看前缀——要考虑每个前缀实际能触达的执行路径。python:* / node:* 这种本质是”允许任意代码”的前缀,应该 deny 或要求 classifier 逐条判断。
Path Pattern:4 种权限规则的路径语法
Claude Code 的权限规则支持的路径语法有 4 种(utils/sandbox/sandbox-adapter.ts 第 83-97 行):
| 前缀 | 含义 |
|---|---|
//path | 绝对路径(转换为 /path) |
/path | 相对于 settings 文件所在目录(展开为 $SETTINGS_DIR/path) |
~/path | 用户 home 目录(由 sandbox-runtime 处理) |
./path 或 path | 相对路径(由 sandbox-runtime 处理) |
/path 这个语义是 Claude Code 特有的——以单斜线开头不代表绝对路径,代表”相对于我这个 settings.json 所在目录”。这让一个 settings 文件可以在不同机器上复用,只要目录结构相对一致。
给自研 agent 的启示:设置的路径语法要支持相对于配置文件本身——绝对路径在跨机器 / 容器 / CI 场景会崩。提供一个 “相对于 settings” 的约定能解决 80% 的配置漂移问题。
四层防线:纵深防御总图
Claude Code 的安全模型本章内四层同时生效,再叠加 执行环境 层的第五层:
权限决策层(本章):
1. Mode —— 当前 permission mode 的默认行为
2. Rules —— allow / deny / ask 规则(按 source 优先级合并)
3. Classifier —— auto mode 下 bash LLM 分类器判断
4. Hooks —— PreToolUse hook 可以拦截 / 修改 / 注入 context
执行环境层(另一章):
5. Platform Sandbox —— Seatbelt / bwrap 进程级隔离
任何一层拒绝 → 操作被拒。这是安全的”合取”模型:
- mode 是
default但 rule 说allow→ 允许(rule 覆盖 mode 默认) - mode 是
bypassPermissions但 rule 说deny→ 拒绝(deny 始终生效) - mode allow、rule allow,但 hook 返回 deny → 拒绝
- 一切都 allow,但 bwrap 阻止了文件写 → 拒绝(执行环境层兜底)
bypassPermissions 不能绕过 hook,也不能绕过进程沙箱——这是关键设计。“最大权限”mode 只放开默认行为,不放开显式规则、hook 拦截、OS 级隔离。
源码上的佐证:permissionDecision 的优先级合并逻辑里,deny 始终胜过 allow——这是fail-closed 默认。
--dangerously-skip-permissions:最后的逃生舱
这是一个刻意起得难看的 CLI flag,绕过整个权限系统。两层设计意图:
- 用户必须主动传——不是默认开启
- 名字本身就是警告——不叫
--auto-approve,叫 “dangerously”
配合源码 sandboxOverride 决策理由里的 'dangerouslyDisableSandbox'——任何通过这个 flag 豁免的决策,理由都明确记录。
给自研 agent 的启示:危险的逃生舱命名要违反 UX 惯例——故意不友好。让用户 every time 都要多想一秒。
给自研 agent 的要点
- 权限 mode 是 UI 状态,不是 config。7 种 mode 各自有符号 / 颜色 / UI 呈现,用户切换时必须有视觉反馈
- 规则来源要枚举化:userSettings / projectSettings / policySettings / cliArg / session / command——合规审计的时候你会需要溯源
- 决策理由分类要细:11 种
PermissionDecisionReason让 “为什么 deny” 可解释。二元决策 + 多种理由是对的抽象 - Hook 要三层 API:plain text(简单)→ JSON decision(中等)→ updatedInput + systemMessage(高级)。降低门槛 + 高上限
- Hook 不只是 yes/no,还可以 修改参数、注入 system message、打断 agent 循环——是一等扩展面
- 10 分钟 hook 超时:hook 可以跑 lint / test / 复杂分析,不是快速拦截器
- 权限层和执行环境层是正交的两层——
bypassPermissions跳过前者不跳过后者。见 执行环境 - Bash classifier 用 LLM 判断命令安全性是个方向——2 阶段(fast/thinking)+ 完整遥测,值得学
DANGEROUS_PATTERNS是对抗者视角——python:*允许 prefix 等于允许任意代码。allowlist 设计要看实际执行路径不是字面名字- Deny 始终胜过 allow,fail-closed 默认。
bypassPermissions只绕过默认行为,不绕 deny list 和 hook - 危险入口的命名违反 UX 惯例——
--dangerously-skip-permissions是故意难看的,让用户停一下 - 路径语法支持相对于 settings 文件——跨机器 / CI / 容器 workflow 的必需特性