KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
04 · 权限与安全 — keel 龙骨
权限系统是 Agent Harness 里唯一「不能出错」的部分:一次误放行可能意味着 rm -rf、凭据外泄或仓库被改写。Claude Code 2.1.88 的处理方式是把拒绝放在最前、把放行放在最后,并在中间插两层「不可绕过」的闸门。
权限系统是 Agent Harness 里唯一「不能出错」的部分:一次误放行可能意味着 rm -rf、凭据外泄或仓库被改写。Claude Code 2.1.88 的处理方式是把拒绝放在最前、把放行放在最后,并在中间插两层「不可绕过」的闸门。
这一章回答:
- 五个外部模式加两个内部模式分别是什么语义;
- 规则引擎的裁决顺序为什么是「整体 deny → 整体 ask → 工具自身 → allow」,而不是反过来;
- Bash 为什么需要 23 项专门检查,检查结果怎么和权限模式交互;
- hook 事件与沙箱配置各自补上了哪块漏洞。
一、模式集合
types/permissions.ts 把权限模式严格分成两层:
export const EXTERNAL_PERMISSION_MODES = [
'acceptEdits', 'bypassPermissions', 'default', 'dontAsk', 'plan',
] as const
export type InternalPermissionMode = ExternalPermissionMode | 'auto' | 'bubble'
注释说明了两者的差别:InternalPermissionMode 是「用于类型检查的穷尽联合」,而真正用户可寻址的运行时集合是 INTERNAL_PERMISSION_MODES(settings.json 的 defaultMode、--permission-mode CLI 参数、会话恢复都走它):
export const INTERNAL_PERMISSION_MODES = [
...EXTERNAL_PERMISSION_MODES,
...(feature('TRANSCRIPT_CLASSIFIER') ? (['auto'] as const) : ([] as const)),
] as const
也就是说:auto 模式受编译期 feature flag 门控,bubble 则根本不出现在任何用户可配置集合里——它只被 tools/AgentTool/forkSubagent.ts: FORK_AGENT 用,语义是「把权限提示冒泡到父终端」。子 Agent 自己弹不出对话框,所以必须把裁决权交回去。
dontAsk 的语义最容易被误解。它不是「不问就直接允许」,而是「不问就直接拒绝」——实现在 utils/permissions/permissions.ts: hasPermissionsToUseTool 的收口处,注释写得很直白:「dontAsk 的语义是在最后一步把 ask 强转成 deny。这样前面任何早返回都绕不过它。」
二、四级(实为十步)规则链
权限裁决有两个容易混淆的函数:
utils/permissions/permissions.ts: checkRuleBasedPermissions(第 1059 行)——只回答「规则层面有没有异议」,无异议时返回null;utils/permissions/permissions.ts: hasPermissionsToUseToolInner(第 1144 行)——完整链。
真正的顺序链在 hasPermissionsToUseToolInner,逐步是:
| 步骤 | 内容 | 关键细节 |
|---|---|---|
| 1a | 整把工具被 deny 规则挡住 | getDenyRuleForTool,直接 deny,message 为 Permission to use <tool> has been denied. |
| 1b | 整把工具存在 ask 规则 | 若满足「沙箱自动放行」四条件则继续往下,否则立刻 ask |
| 1c | 调用 tool.checkPermissions(parsedInput, context) |
inputSchema.parse 成功才调用;初始值 { behavior: 'passthrough' } |
| 1d | 工具自身返回 deny | 直接返回;注释说明这一步也兜住被包装进 subcommandResults 的 Bash 子命令拒绝 |
| 1e | tool.requiresUserInteraction?.() 且工具返回 ask |
即使 bypass 模式下也必须人工交互 |
| 1f | 工具吐出的内容级 ask 规则(如 Bash(npm publish:*)) |
不受 bypassPermissions 影响 |
| 1g | decisionReason.type === 'safetyCheck' |
同样不受 bypass 影响——.git/、.claude/、.vscode/、shell 配置文件这类路径必须继续 ask |
| 2a | 模式级 bypass | mode === 'bypassPermissions',或 mode === 'plan' && isBypassPermissionsModeAvailable |
| 2b | 整把工具被 allow 规则放行 | toolAlwaysAllowedRule |
| 3 | 剩余的 passthrough 一律收口成 ask |
并附带 suggestions(用于 UI 提示可记的规则) |
顺序设计的核心是否决权优先:1a/1b 在任何工具自检之前,2a/2b 在任何放行之前。这样「用户显式写的 deny 规则」永远不会被工具自己的宽容实现、或某个 bypass 模式悄悄绕过。
1e/1f/1g 三步是「不可 bypass」的例外集合——它们夹在工具自检(1c/1d)与模式放行(2a/2b)之间,所以 bypassPermissions 也绕不过去。第 1g 步的注释点明了具体对象:.git/、.claude/、.vscode/、shell 配置文件。
八个规则来源定义在 types/permissions.ts: PermissionRuleSource:
userSettings | projectSettings | localSettings | flagSettings
| policySettings | cliArg | command | session
其中 policySettings / flagSettings / command 是只读来源。deletePermissionRule(第 1309 行)对这三者直接抛错:
if (rule.source === 'policySettings' || rule.source === 'flagSettings' || rule.source === 'command') {
throw new Error('Cannot delete permission rules from read-only settings')
}
剩下的 userSettings / projectSettings / localSettings 才走 switch 落到各自的持久化目标。
外层还有一个 auto 模式的加密闸门:hasPermissionsToUseTool 在拿到 ask 结果后,如果模式是 auto(或 plan 且 auto 模式激活),会先走 AI classifier。但 safetyCheck 类型里只有 classifierApprovable 为真才能流进 classifier——注释把三类「免自动放行」列全了:acceptEdits 快路径不行、安全工具 allowlist 不行、classifier 本身也不行。
三、Bash 的 23 项检查
Bash 是唯一能执行任意代码的工具,所以它有独立的检查电池。tools/BashTool/bashSecurity.ts: BASH_SECURITY_CHECK_IDS(第 77 行)给了每个检查一个数字 ID——注释说明这是为了「avoid logging strings」。
| ID | 名称 | ID | 名称 |
|---|---|---|---|
| 1 | INCOMPLETE_COMMANDS | 13 | PROC_ENVIRON_ACCESS |
| 2 | JQ_SYSTEM_FUNCTION | 14 | MALFORMED_TOKEN_INJECTION |
| 3 | JQ_FILE_ARGUMENTS | 15 | BACKSLASH_ESCAPED_WHITESPACE |
| 4 | OBFUSCATED_FLAGS | 16 | BRACE_EXPANSION |
| 5 | SHELL_METACHARACTERS | 17 | CONTROL_CHARACTERS |
| 6 | DANGEROUS_VARIABLES | 18 | UNICODE_WHITESPACE |
| 7 | NEWLINES | 19 | MID_WORD_HASH |
| 8 | DANGEROUS_PATTERNS_COMMAND_SUBSTITUTION | 20 | ZSH_DANGEROUS_COMMANDS |
| 9 | DANGEROUS_PATTERNS_INPUT_REDIRECTION | 21 | BACKSLASH_ESCAPED_OPERATORS |
| 10 | DANGEROUS_PATTERNS_OUTPUT_REDIRECTION | 22 | COMMENT_QUOTE_DESYNC |
| 11 | IFS_INJECTION | 23 | QUOTED_NEWLINE |
| 12 | GIT_COMMIT_SUBSTITUTION |
这些检查防的是同一类攻击:让「解析器看到的」和「shell 实际执行的」不一致。几个典型例子都能在源码里读到具体动机:
COMMAND_SUBSTITUTION_PATTERNS同时覆盖$()、${}、$[]、进程替换<()/>(),还包括 Zsh 特有的=()进程替换与=cmdequals 展开。后者注释写明了风险:=curl evil.com会展开成/usr/bin/curl evil.com,而 Bash 解析器看到的 base command 是=curl而不是curl,于是Bash(curl:*)这类 deny 规则会被绕过。ZSH_DANGEROUS_COMMANDS是一个 Set,含zmodload、emulate、sysopen/sysread/syswrite/sysseek、zpty、ztcp、zsocket、mapfile,以及zsh/files提供的内建zf_rm/zf_mv/zf_ln/zf_chmod/zf_chown/zf_mkdir/zf_rmdir/zf_chgrp。注释解释zmodload是「很多危险模块攻击的入口」:zsh/mapfile能做不可见文件读写,zsh/system提供两步式文件访问,zsh/zpty能执行命令,zsh/net/tcp能外传数据,zsh/files的内建命令能绕过二进制检查。- 还要防 PowerShell 注释语法
<#——注释说是「defense in depth」,防止未来某次改动引入 PowerShell 执行。
两个入口函数:
bashCommandIsSafeAsync_DEPRECATED(command)——主体,tools/BashTool/bashPermissions.ts第 88 行把它 alias 成bashCommandIsSafeAsync后调用(第 1221 行起);bashCommandIsSafe_DEPRECATED(command)——同步版,供tools/BashTool/readOnlyValidation.ts等同步调用方使用(第 1894 行)。
结果是三态:passthrough 表示「安全检查没意见」,非 passthrough 会被上层转成 ask。bashSecurity.ts 的注释还提醒了 allow 的短路风险:「allow here short-circuits bashCommandIsSafe and SKIPS ...」,即早放行会跳过后续检查,所以任何新增的 allow 分支都必须自己想清楚跳过了什么。
四、Hooks:27 个事件
entrypoints/sdk/coreTypes.ts: HOOK_EVENTS(第 25 行)是 hook 事件的唯一真源。同一份列表在 entrypoints/sdk/coreSchemas.ts: HOOK_EVENTS(第 355 行)里再声明了一次,后者是 HookEventSchema 的构建依据。
完整列表(27 个):
PreToolUse, PostToolUse, PostToolUseFailure, Notification,
UserPromptSubmit, SessionStart, SessionEnd, Stop, StopFailure,
SubagentStart, SubagentStop, PreCompact, PostCompact,
PermissionRequest, PermissionDenied, Setup, TeammateIdle,
TaskCreated, TaskCompleted, Elicitation, ElicitationResult,
ConfigChange, WorktreeCreate, WorktreeRemove,
InstructionsLoaded, CwdChanged, FileChanged
utils/hooks/hookEvents.ts 另有一个 ALWAYS_EMITTED_HOOK_EVENTS = ['SessionStart', 'Setup']——这两个事件无论配置怎样都会发出,其余按注册情况决定。hook 的注册面很广:schemas/hooks.ts 用 z.partialRecord(z.enum(HOOK_EVENTS), ...) 做校验,utils/hooks/registerFrontmatterHooks.ts 与 registerSkillHooks.ts 都遍历 HOOK_EVENTS 注册,utils/hooks/sessionHooks.ts 也按同一份列表做遍历。
与权限直接相关的是 PermissionRequest / PermissionDenied 这一对:它们让外部进程能旁观甚至影响裁决过程,而 PreToolUse 的 allow 结果依然绕不过 1f/1g 两个「不可 bypass」的闸门。
五、沙箱配置
沙箱的类型定义在 entrypoints/sandboxTypes.ts,由三个 schema 组成:SandboxNetworkConfigSchema、SandboxFilesystemConfigSchema、SandboxSettingsSchema。
SandboxSettingsSchema 的字段(第 91 行起):
enabled、failIfUnavailable——后者的描述说明了两种部署姿态:默认 false 时「失败就警告并裸跑」,设为 true 时「缺依赖 / 平台不支持就直接启动报错」,定位是给「要求沙箱作为硬门禁的 managed-settings 部署」用;autoAllowBashIfSandboxed——沙箱内 Bash 可跳过 ask 规则;allowUnsandboxedCommands(默认 true)——控制dangerouslyDisableSandbox参数是否还有效;设为 false 时该参数被完全忽略,所有命令必须沙箱内跑;network.allowedDomains、filesystem.allowWrite/denyWrite/denyRead/allowRead;excludedCommands、ripgrep(自定义 bundled ripgrep 的 command/args)、ignoreViolations、enableWeakerNestedSandbox、enableWeakerNetworkIsolation。
allowRead 的注释说明了它与 denyRead 的优先级关系:「Paths to re-allow reading within denyRead regions. Takes precedence over denyRead for matching paths.」enableWeakerNetworkIsolation 的描述则直白标了风险——它为让 Go 系 CLI 在 MITM 代理下验证 TLS 证书而开放 com.apple.trustd.agent,注释写着「Reduces security — opens a potential data exfiltration vector through the trustd service」。
有个未被文档化的字段值得注意:注释提到 enabledPlatforms 是通过 .passthrough() 读到的隐藏设置,加入动机是让某企业客户先在 macOS 上启用 autoAllowBashIfSandboxed,等 Linux/WSL 沙箱更成熟再放开。
沙箱能力的实现与查询在 utils/sandbox/sandbox-adapter.ts,其中 isAutoAllowBashIfSandboxedEnabled() 定义在第 469 行、并在第 888 / 934 行作为接口成员导出。
于是 1b 步那四个条件的完整形状是:
tool.name === BASH_TOOL_NAME &&
SandboxManager.isSandboxingEnabled() &&
SandboxManager.isAutoAllowBashIfSandboxedEnabled() &&
shouldUseSandbox(input)
四个都成立才跳过「整把工具 ask」,把裁决权下放给 Bash 的命令级检查。这条设计的逻辑是:沙箱已经提供了真实边界,再用整把工具的 ask 去挡只是浪费用户注意力;但进不了沙箱的命令必须回到原规则。
代码地图
| 机制 | 位置 | 要点 |
|---|---|---|
| 外部模式集合 | types/permissions.ts: EXTERNAL_PERMISSION_MODES |
acceptEdits / bypassPermissions / default / dontAsk / plan |
| 内部模式 | types/permissions.ts: InternalPermissionMode |
External + auto(feature 门控)+ bubble(非用户可寻址) |
| 运行时校验集合 | types/permissions.ts: INTERNAL_PERMISSION_MODES |
settings defaultMode / --permission-mode / 会话恢复的合法集合 |
| 规则来源 | types/permissions.ts: PermissionRuleSource |
8 种:userSettings / projectSettings / localSettings / flagSettings / policySettings / cliArg / command / session |
| 规则层裁决 | utils/permissions/permissions.ts: checkRuleBasedPermissions |
第 1059 行;无异议返回 null |
| 完整裁决链 | utils/permissions/permissions.ts: hasPermissionsToUseToolInner |
第 1144 行;1a→1g、2a→2b、3 全序 |
| 最终收口 | utils/permissions/permissions.ts: hasPermissionsToUseTool |
第 477 行;dontAsk 把 ask 转 deny;auto 走 classifier |
| 回调类型 | hooks/useCanUseTool.tsx: CanUseToolFn |
UI 与 headless 共用的裁决入口签名 |
| Bash 检查 ID 表 | tools/BashTool/bashSecurity.ts: BASH_SECURITY_CHECK_IDS |
第 77 行;1..23,用数字避免日志里出现字符串 |
| Zsh 危险命令表 | tools/BashTool/bashSecurity.ts: ZSH_DANGEROUS_COMMANDS |
第 45 行;zmodload / sysopen / zpty / zf_rm 等 |
| 命令替换模式表 | tools/BashTool/bashSecurity.ts: COMMAND_SUBSTITUTION_PATTERNS |
第 16 行;含 =cmd equals 展开绕过 deny 规则 |
| 异步检查入口 | tools/BashTool/bashSecurity.ts: bashCommandIsSafeAsync_DEPRECATED |
第 2426 行;bashPermissions.ts 第 88 行 alias |
| 同步检查入口 | tools/BashTool/bashSecurity.ts: bashCommandIsSafe_DEPRECATED |
第 2257 行;readOnlyValidation.ts 第 1894 行调用 |
| hook 事件真源 | entrypoints/sdk/coreTypes.ts: HOOK_EVENTS |
第 25 行;共 27 个事件 |
| hook 事件镜像 | entrypoints/sdk/coreSchemas.ts: HOOK_EVENTS |
第 355 行;HookEventSchema 的枚举来源 |
| 恒定事件 | utils/hooks/hookEvents.ts: ALWAYS_EMITTED_HOOK_EVENTS |
SessionStart / Setup |
| 沙箱设置 schema | entrypoints/sandboxTypes.ts: SandboxSettingsSchema |
第 91 行;enabled / autoAllowBashIfSandboxed / allowUnsandboxedCommands 等 |
| 文件系统白黑名单 | entrypoints/sandboxTypes.ts: SandboxFilesystemConfigSchema |
第 47 行;allowRead 优先于 denyRead |
| 自动放行查询 | utils/sandbox/sandbox-adapter.ts: isAutoAllowBashIfSandboxedEnabled |
第 469 行定义;在 1b 步与 Bash 自身检查里复用 |
关键取舍
「拒绝优先、放行最后」的顺序,代价是 ask 路径变长。
每一步都可能提前返回 deny/ask,只有全部走完才轮到 2a/2b 放行。结果是绝大多数无规则命中的工具调用都要穿过 1a→1g 七步,才能落到最后的 passthrough → ask。换来的是可证明性质:用户写下的 deny 规则在任何模式下都不会被绕过。
1e/1f/1g 三个「不可 bypass」例外,代价是 bypassPermissions 名不副实。
用户在 bypass 模式下的心理预期是「什么都不会问」,但内容级 ask 规则、requiresUserInteraction 工具与 safetyCheck(.git/、.claude/、.vscode/、shell 配置)依然会弹框。这是有意的——这些是「用户自己显式配过」或「不可逆破坏」的场景,让模式覆盖它们等于静默销毁用户的显式意图。
Bash 用 23 项正则式检查,代价是持续对抗解析差异。
每一项都对应一个已知的解析歧义(Zsh equals 展开、mapfile、IFS 注入、Unicode 空白、注释与引号不同步……)。这是黑名单思路的固有成本:新 shell 特性出现就要加一条。真正让这套东西可控的是它把检查结果降级为 ask 而不是 deny——误报的代价是用户多点一次确认,不是功能不可用。
沙箱自动放行把「整把工具 ask」换成了「命令级检查」,代价是正确性依赖 shouldUseSandbox。
只要有一条命令进不了沙箱,它就必须回到原 ask 规则。所以 1b 的四个条件是 && 关系而不是「沙箱开着就放行」——判断「这条命令会不会真的进沙箱」成了安全边界的一部分。
hook 事件从 14 个常用的扩到 27 个,代价是测试与文档面变大。
列表里既有工具生命周期(PreToolUse/PostToolUse/PostToolUseFailure)与压缩(PreCompact/PostCompact),也有环境类事件(ConfigChange/CwdChanged/FileChanged/InstructionsLoaded)和协作类(TeammateIdle/TaskCreated/TaskCompleted)。事件越多,单个事件的行为契约越容易被忽略——ALWAYS_EMITTED_HOOK_EVENTS 这个只有两个元素的特殊表就是补丁之一。
自测题
hasPermissionsToUseToolInner的第 1c 步用tool.inputSchema.parse(input)(会抛异常)而不是safeParse。这个选择让「schema 校验失败」落到了哪条路径上?对用户体验是更好还是更差?dontAsk被实现在最外层收口处而不是模式判断里。请说明为什么这比「在 2a 附近判断 dontAsk」更安全,并举出一条会绕过后者、但绕不过前者的路径。- 沙箱自动放行的四个条件是
&&。如果把它改成「只要isSandboxingEnabled()就跳过整把工具 ask」,请描述一个具体的逃逸场景(提示:dangerouslyDisableSandbox与allowUnsandboxedCommands)。 ZSH_DANGEROUS_COMMANDS里包含mapfile,但注释说它「Not actually a command」。为什么一个不存在于 PATH 的名字也要被列进黑名单?这与zmodload的组合攻击有什么关系?- 27 个 hook 事件里,
PermissionRequest与PreToolUse都可能影响裁决。如果你要写一个「记录所有被拒绝的工具调用及其原因」的 hook,应该挂在哪两个事件上?为什么单挂一个不够?