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 的处理方式是把拒绝放在最前、把放行放在最后,并在中间插两层「不可绕过」的闸门。

这一章回答:

  1. 五个外部模式加两个内部模式分别是什么语义;
  2. 规则引擎的裁决顺序为什么是「整体 deny → 整体 ask → 工具自身 → allow」,而不是反过来;
  3. Bash 为什么需要 23 项专门检查,检查结果怎么和权限模式交互;
  4. 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。这样前面任何早返回都绕不过它。」

二、四级(实为十步)规则链

权限裁决有两个容易混淆的函数:

真正的顺序链在 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 实际执行的」不一致。几个典型例子都能在源码里读到具体动机:

两个入口函数:

结果是三态: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 行起):

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 这个只有两个元素的特殊表就是补丁之一。

自测题

  1. hasPermissionsToUseToolInner 的第 1c 步用 tool.inputSchema.parse(input)(会抛异常)而不是 safeParse。这个选择让「schema 校验失败」落到了哪条路径上?对用户体验是更好还是更差?
  2. dontAsk 被实现在最外层收口处而不是模式判断里。请说明为什么这比「在 2a 附近判断 dontAsk」更安全,并举出一条会绕过后者、但绕不过前者的路径。
  3. 沙箱自动放行的四个条件是 &&。如果把它改成「只要 isSandboxingEnabled() 就跳过整把工具 ask」,请描述一个具体的逃逸场景(提示:dangerouslyDisableSandbox 与 allowUnsandboxedCommands)。
  4. ZSH_DANGEROUS_COMMANDS 里包含 mapfile,但注释说它「Not actually a command」。为什么一个不存在于 PATH 的名字也要被列进黑名单?这与 zmodload 的组合攻击有什么关系?
  5. 27 个 hook 事件里,PermissionRequest 与 PreToolUse 都可能影响裁决。如果你要写一个「记录所有被拒绝的工具调用及其原因」的 hook,应该挂在哪两个事件上?为什么单挂一个不够?

进入 keel 阅读