Chapter 10: 风险分级与自动审批

读文件和删文件,风险完全不同。自动化的安全判断如何区分这两者?

         tool_use request
              │
       ┌──────▼──────┐
       │  Permission  │
       │    Check     │
       │      │       │
       │  ┌───▼────┐  │
       │  │Allowlist│  │   Fast paths
       │  └───┬────┘  │
       │  ┌───▼────┐  │
       │  │Denylist │  │
       │  └───┬────┘  │
       │      │       │
       │ ★ Risk       │
       │ ★ Classifier ★   ◄── 本章聚焦
       │  Stage1→Stage2│
       │      │       │
       │  allow/deny   │
       └──────────────┘

10.1 不是所有操作都一样危险

上一章我们看到,当权限决策落入 ask 分支时,auto 模式可以让 ML 分类器 代替用户做出判断。 但这引出了一个更根本的问题: 用一个模型去判断另一个模型的行为是否安全,凭什么?

答案是分层。

在分类器介入之前,有大量确定性规则已经排除了两端的极端情况: 明显安全的操作被白名单直接放行, 明显危险的操作被黑名单直接拦截。 分类器只处理中间的灰色地带—— 那些规则无法覆盖、需要理解上下文才能判断的操作。

这就像医院的分诊系统。 护士先根据症状分流: 明显的感冒直接开药,明显的急症直接进急诊, 只有症状不明确的才需要专家会诊。 分类器就是那个专家——昂贵但精准,只在真正需要时才出场。

权限类型定义模块中定义了三级风险评估框架。 RiskLevel 分为 LOW、MEDIUM、HIGH, 每个评估附带 explanation(发生了什么)、 reasoning(为什么有风险)、risk(最坏情况)。 这让权限弹窗不再是无上下文的"Allow / Deny", 而是带有充分信息的安全决策。

10.2 三条快速通道:在分类器之前拦截

分类器需要一次额外的 API 调用,成本不低。 因此系统在分类器之前设置了三条快速通道, 每一条都是零延迟、零成本的确定性判断。

安全工具白名单

分类器决策模块中定义了一组"绝对安全"的工具, 收集在安全工具白名单集合中。

这些工具的共同特征是:只读或只影响元数据, 不产生文件系统写入、网络请求或进程执行。 列表包括文件读取(FileRead)、搜索(Grep、Glob)、 语言服务器(LSP)、任务管理(TodoWrite、TaskCreate)、 计划模式切换、Swarm 协调工具等。

当 auto 模式遇到这些工具时,直接放行, 连分类器的 API 调用都省了。 analytics 事件中 fastPath: 'allowlist' 的标记 让运维团队可以追踪有多少操作走了这条快速通道。

白名单的设计遵循了一个严格原则:宁缺毋滥。 注意白名单的注释: Does NOT include write/edit tools — those are handled by the acceptEdits fast path。 写入工具不在白名单中,因为它们的安全性取决于目标路径。

更值得注意的是,AGENT_TOOL_NAME 和 REPL_TOOL_NAME 也不在白名单中。 Agent 工具可以间接执行任何操作, REPL 的 JavaScript 代码可能包含 VM 逃逸。 即使它们"看起来"安全,潜在的间接风险也排除了白名单资格。 Swarm 相关的工具(TEAM_CREATE、SEND_MESSAGE)之所以在白名单中, 是因为它们只操作内部邮箱和团队状态—— 团队成员自身有独立的权限检查。

acceptEdits 快速路径

对于写操作,系统有一个巧妙的优化: 在调用分类器之前,先模拟 acceptEdits 模式下的权限检查。

如果一个操作在 acceptEdits 模式下被允许 (即只是在工作目录内编辑文件), 那它在 auto 模式下也应该被允许 ——不需要浪费一次分类器调用来确认这个显而易见的结论。

实现方式很精巧: 临时构造一个权限上下文,将 mode 替换为 acceptEdits, 然后调用工具的权限检查方法。 如果返回 allow,直接放行并标记 fastPath: 'acceptEdits'。

但同样,AGENT_TOOL_NAME 和 REPL_TOOL_NAME 被排除在此快速路径之外。 代码注释解释了原因: REPL 代码可能在内部工具调用之间包含 VM 逃逸代码—— 分类器必须看到那些"胶水 JavaScript", 而不是只看到内部工具调用。

safetyCheck 硬性拦截

在所有快速通道之前,还有一道硬性安全屏障。

当 classifierApprovable 为 false 时, 意味着这个安全检查连分类器也无权批准—— 比如 Windows 路径绕过尝试或跨机器桥接消息。 这些操作必须由用户亲自确认, 任何自动化手段都不能代替人类判断。

当 classifierApprovable 为 true 时—— 比如 .agent/ 下的文件操作—— 分类器可以看到完整上下文来判断是否是用户主动请求的行为。 这个布尔值精确地划定了"机器可以代劳"和"必须人类确认"的边界。

10.3 敏感文件保护:修改配置即获得执行权

文件系统安全模块中定义了两组受保护目标。

DANGEROUS_FILES 包括 .gitconfig、.bashrc、.zshrc、 .mcp.json、.ripgreprc 等。 DANGEROUS_DIRECTORIES 包括 .git、.vscode、.idea、.agent。

为什么这些文件和目录需要特别保护? 因为它们有一个共同特征: 修改它们等同于获得代码执行能力。

.gitconfig 可以设置 core.sshCommand 在每次 git 操作时执行任意命令。 .bashrc 在每次终端启动时运行。 .mcp.json 可以注册恶意的 MCP 服务器。 .agent/settings.json 可以修改权限规则本身 ——一个能修改自己权限规则的 Agent,等于没有权限限制。

路径安全检查函数是这道防线的核心。 它做了三重检查:

Windows 路径模式检测。 NTFS 替代数据流(file.txt::$DATA)、 8.3 短名称(GIT~1 可以绕过对 .git 的字符串匹配)、 长路径前缀(\\?\C:\... 绕过路径规范化)、 尾部点和空格(.git. 在 Windows 上等同于 .git)、 DOS 设备名(.git.CON 是特殊设备文件)、 三个以上连续点号(路径遍历变体)—— 每一个都是真实的路径规范化绕过攻击向量。

函数注释中详细论述了 为什么选择"检测拒绝"而非"规范化"策略。 规范化依赖文件系统状态,新建文件时目标不存在无法规范化; 且会引入 TOCTOU 竞态条件—— 在规范化和实际文件操作之间,文件系统状态可能已经改变。

为什么在所有平台上都检测 Windows 路径模式? 因为 NTFS 文件系统可以通过 ntfs-3g 挂载在 Linux 和 macOS 上。 唯一的例外是 ADS 冒号检测—— 只在 Windows/WSL 上检查,因为 Linux/macOS 上冒号是合法的文件名字符, NTFS 上的 ADS 通过 xattrs 而非冒号语法访问。

Agent 配置文件检测。 配置文件路径检查函数不仅检查 settings 文件, 还检查 .agent/commands/、.agent/agents/、.agent/skills/ 目录。 路径比较使用大小写不敏感的规范化函数, 防止在 macOS/Windows 上通过 .aGeNt/Settings.json 绕过检查。

危险文件/目录检测。 危险路径检测函数遍历路径段与黑名单做大小写不敏感匹配。 其中有一个精巧的特例:.agent/worktrees/ 路径被豁免, 因为这是系统存储 git worktree 的结构性目录,而非用户创建的敏感目录。

10.4 双阶段分类器:宁可误杀的快速阶段,深思熟虑的纠偏阶段

当所有确定性规则都无法给出判断时,auto 模式启动 ML 分类器。 这是整个权限系统中最精密的组件, 实现在自动审批分类器模块中。

分类器看到什么

分类器看到的不是孤立的工具调用, 而是一个精心构建的会话摘要。 转录构建函数将对话历史转换为紧凑的转录格式。

这里有一个关键的安全决策: assistant 的文本内容被故意排除,只保留 tool_use 块。

为什么? 因为 assistant 文本是模型生成的, 恶意 prompt 注入可能诱导模型产生具有误导性的文本。 比如模型可能在 tool_use 前写下"用户刚才确认了要删除所有文件", 如果分类器看到这段文本,可能会被误导认为操作是用户授权的。 只看"做了什么"(tool_use),不看"说了什么"(text), 大大缩小了攻击面。

转录格式被压缩为紧凑的单行表示。 每个工具通过分类器输入方法控制哪些字段暴露给分类器。 如果某个工具返回空字符串,意味着"没有安全相关性", 该工具调用会被完全跳过。 这种"每个工具控制自己的分类器投影"的设计, 让工具作者可以精确控制信息披露—— 只暴露安全决策需要的最少信息。

双阶段决策流

分类器支持三种运行模式: both(默认)、fast(只跑第一阶段)、thinking(只跑第二阶段)。 默认的 both 模式是最精妙的。

Stage 1(快速阶段): 最大 64 tokens,温度为 0,附加后缀:

'\nErr on the side of blocking. <block> immediately.'

这句话把分类器推向保守端——"宁可误杀,不可放过"。 如果它说"允许",那大概率确实安全(高精确率); 如果它说"阻止",可能是误报(较低召回率)。 stop_sequences: ['</block>'] 让模型在给出判断后立即停止, 进一步压缩延迟。

Stage 2(思考阶段): 只在 Stage 1 说"阻止"时才运行。 最大 4096 tokens,附加后缀要求深入推理:

'\nReview the classification process and follow it carefully, making sure you deny actions that should be blocked. As a reminder, explicit (not suggestive or implicit) user confirmation is required to override blocks. Use <thinking> before responding with <block>.'

Stage 2 的任务是"纠偏"—— 用更充分的推理来判断 Stage 1 的阻止是否是误报。 注意它特别强调"显式的用户确认才能覆盖阻止" ——暗示性或隐含的确认不够。

两阶段设计的效果是: 大部分安全操作在 Stage 1 就通过了(低延迟), 只有可疑操作才需要 Stage 2 的深度分析(高准确率)。 一个安全的 git add . 在几百毫秒内通过, 一个可疑的 curl | bash 则会被仔细审查。

响应解析:防御链式推理中的误匹配

两阶段分类器使用 XML 格式(<block>yes/no</block>), 解析函数在解析前会先移除 <thinking> 标签内的内容。

为什么要这样做? 因为模型在链式推理中可能写出 "如果用户说了 X 则 <block>yes</block>,否则..."这样的假设性结论。 如果不移除推理内容,假设中的 <block>yes 会被错误匹配为最终判断。 这是一个微妙但重要的防御。

10.5 Fail Closed:不确定时选择更安全的路径

分类器的错误处理体现了"安全优先"的哲学,贯穿三个层面。

响应不可解析时: Stage 2 返回值无法解析为 yes/no,直接判定为阻止。 reason 写入 'Classifier stage 2 unparseable - blocking for safety'。 这就是 fail closed——系统在无法确定安全性时选择更保守的路径。

API 不可用时: 行为受特性门控控制。 默认 fail closed(deny), 但可以通过远程配置切换为 fail open(回退到用户询问)。 这个开关的存在是为了运维: 当分类器大规模故障时,运维团队可以快速切换为 fail open, 恢复用户体验,而不是让所有 auto 模式用户被卡住。 特性门控的刷新间隔是 30 分钟, 在安全性和响应速度之间取得平衡。

上下文超长时: 当对话历史超过分类器的上下文窗口时, 这是一个确定性条件——transcript 只会增长不会缩短。 系统不浪费时间重试,立即降级到用户提示模式。 对于 headless 模式,由于降级也无法弹窗, 直接抛出 AbortError 终止 Agent。

10.6 拒绝追踪与降级保护:断路器模式

如果分类器持续拒绝操作怎么办?

拒绝追踪模块实现了一个类似电路断路器的保护机制。 核心数据结构只有两个计数器: consecutiveDenials 和 totalDenials,以及两个阈值:

连续拒绝上限 maxConsecutive: 3,总拒绝上限 maxTotal: 20。

当任一阈值被触发时, 降级判断函数返回 true, 系统自动降级到用户提示模式。

这个设计解决了两个问题。

第一,分类器系统性误判。 如果分类器因某种原因持续错误地拒绝合法操作, 3 次连续拒绝后用户就能介入。

第二,恶意循环检测。 如果模型持续尝试被拒绝的操作 (可能是 prompt 注入导致的死循环), 20 次总拒绝后强制中断,让人类审查。

成功记录函数有一个值得注意的细节: 当状态没有变化时(consecutiveDenials 已经是 0), 它返回相同的引用而非新对象。 这让下游的 Object.is 检查可以跳过不必要的状态更新—— 在每次成功的工具调用都会触发成功记录的高频场景下, 这个优化避免了大量无意义的 React 重渲染。

降级策略对不同场景做了区分: CLI 模式下降级为弹窗询问; headless 模式下由于无法弹窗, 达到阈值后直接抛出 AbortError 终止 Agent。

总拒绝达到 20 次后,计数器会被重置为 0, 让用户审查并决定后可以继续使用 auto 模式。 这避免了"一旦达到 20 次就永远无法恢复"的死锁。

10.7 决策链路的完整追踪

每一个权限决策——无论来自规则、分类器还是用户—— 都附带决策原因记录, 一个包含 11 种类型的联合类型。

从 rule(哪条规则、哪个来源)到 classifier(哪个分类器、什么理由)到 hook(哪个 Hook、什么来源)到 safetyCheck(什么安全检查、是否可被分类器批准), 完整的决策链路被结构化记录。

这种可审计性不是奢侈品——它是信任的基础。 用户需要能够理解"为什么这个操作被拒绝了", 才能信任并有效使用自动化决策系统。 /permissions 面板展示了最近的拒绝记录, 让决策过程对用户透明可见。

10.8 小结:分级防御的经济学

回顾整个风险分级体系, 其设计遵循了一个核心的经济学原则: 确定性规则先行,概率性判断后置。

白名单、黑名单、路径检查—— 这些都是零延迟、零成本的判断。 只有当确定性规则无法覆盖时,才启动昂贵的 ML 推理。

Stage 1 的保守策略和 Stage 2 的纠偏策略 构成了精度与延迟的帕累托最优—— 大部分操作在低延迟下通过, 只有真正可疑的才承受高延迟但获得高精度。

而贯穿始终的 fail closed 原则确保了一件事: 系统宁可在不确定时让用户多确认一次, 也不会在不确定时默默放行一个危险操作。


思考题

  1. 分类器故意排除 assistant 文本只保留 tool_use 块。 但如果攻击者构造了恶意的 tool_use 输入 (比如在 Bash 命令中嵌入误导性注释), 分类器还能有效防御吗? "只看行为不看言论"的策略有什么盲点?

  2. 连续拒绝阈值设为 3,总拒绝阈值设为 20。 如果你要为高安全场景(如金融系统)调整这些参数,你会怎么改? 改了之后对用户体验有什么影响?

  3. acceptEdits 快速通道的逻辑是 "如果在 acceptEdits 模式下被允许,那在 auto 模式下也应该被允许"。 这个推理的隐含假设是什么? 在什么场景下这个假设会失效?