Chapter 9: 权限模型:四层防线的设计

Agent 能做一切,但不应该做一切。权限系统是 Harness 最沉重的职责。

           User Request
                │
         ┌──────▼──────┐
         │  Agent Loop  │
         │   ┌─────┐   │
         │   │ LLM │   │
         │   └──┬──┘   │
         │      │      │
         │  tool_use   │
         │      │      │
         │ ★ Permission ★       ◄── 本章聚焦
         │    Check    │
         │      │      │
         │   [Tool]    │
         └─────────────┘

9.1 一把万能钥匙带来的恐惧

传统软件的权限模型是"按钮模型"——用户按下删除键,程序删除文件, 因果链清晰可控。

AI Agent 彻底打破了这个范式。

当你告诉它"帮我整理项目结构",它可能在一次对话中产生几十次工具调用: 读文件、写文件、执行 shell 命令、搜索代码。 每一次工具调用都可能越界。

这种越界风险来自三个根源。

不可预测性。 语言模型的输出是概率性的,同一个 prompt 在不同上下文下可能产生完全不同的 工具调用序列。你无法在编译期穷举所有可能行为。 这和传统软件的确定性逻辑完全不同——你不能写一个 switch-case 来覆盖所有情况。

能力放大效应。 一旦 Agent 获得了 Bash 工具的使用权,它理论上拥有了操作系统级别的一切能力 ——安装软件、修改配置、发送网络请求、删除整个目录。 一个 rm -rf / 的距离,只隔着一个 token。 这种能力放大在其他编程范式中没有先例。

提示注入风险。 恶意指令可能隐藏在看似无害的文件注释、网页内容、API 响应中。 Agent 读取了被投毒的数据后,可能被诱导执行非预期操作。 一个看似正常的"请帮我优化这段代码"请求,如果代码注释中嵌入了恶意指令, 后果不堪设想。

如果每次工具调用都弹窗询问,用户体验将惨不忍睹; 如果完全放开权限,安全隐患又是灾难性的。 这就是权限系统要解决的核心矛盾:在安全与效率之间找到动态平衡点

9.2 四层纵深防御:机场安检的隐喻

该系统的解决方案可以用机场安检来类比。 你不会只设一道关卡——而是层层过滤: 安检门筛掉金属物品,X 光扫描行李内容,人工抽检可疑物品,登机口做最终确认。 每一层针对不同威胁,任何一层拦截都能阻止危险操作通过。

这四层在代码中的对应是:

  • Layer 1: 工具自检(安检门)——每个工具知道自己的危险边界,主动检查输入
  • Layer 2: 规则引擎(X 光机)——根据配置的 deny/allow/ask 规则做确定性判断
  • Layer 3: ML 分类器(人工抽检)——用另一个模型判断灰色地带的操作
  • Layer 4: 用户审批(登机口确认)——最终兜底,人类做出最后决策

所有工具调用的权限检查都汇聚在一个统一入口函数中。 它创建一个 Promise,将整个四层决策流封装为异步操作。

如果调用方提供了强制决策结果(用于测试或已完成权限检查的场景),直接使用; 否则进入规则引擎的核心流程。 这个函数的返回值只有三种:allowdenyask。 简洁的三值语义让后续所有层的处理逻辑都清晰可控。

9.3 Layer 1: 工具自检——每个工具知道自己的边界

权限检查的第一步发生在权限模块的核心检查函数中。 它按严格的步骤顺序执行,每一步都有明确的优先级语义。

Step 1a: 全局 deny 规则最先检查。 如果一个工具被配置在 deny 列表中,不做任何进一步判断,直接拒绝。 这是"一票否决"——deny 永远优先于 allow,这是安全设计的基本原则。 deny 规则匹配函数在所有来源的 deny 规则中查找匹配。

Step 1b: 全局 ask 规则。 如果工具被标记为"总是询问",通常会立即进入 ask 流程。 但这里有一个精巧的例外:如果启用了沙箱且命令会在沙箱中执行,可以跳过询问。 因为沙箱本身就是一层保护,叠加询问是多余的。 这个判断通过沙箱管理器的自动放行检查实现。

Step 1c: 调用工具自身的权限检查方法。 这是最关键的一步——每个工具比权限系统更了解自己的风险边界。 以 BashTool 为例,它会解析命令字符串, 逐个检查子命令是否匹配已有的 allow 规则。 比如规则 Bash(git *) 允许所有以 git 开头的命令。 FileEditTool 则会检查目标路径是否在工作目录内、是否命中了敏感文件列表。 这种自检机制让权限判断具备了工具级的上下文感知能力

Step 1d-1g: 四道安全闸门。 工具返回的结果经过四重检查:

Step 1d——工具明确 deny,不可覆盖。 这捕获了工具自检发现的硬性安全问题,比如 Bash 命令中包含被 deny 的子命令。

Step 1e——工具声明需要用户交互(requiresUserInteraction), 即使在 bypass 模式下也必须询问。 AskUserQuestion 工具就属于这类——它的存在意义就是向用户提问,自动允许没有意义。

Step 1f——用户配置的内容级 ask 规则(如 Bash(npm publish:*)), bypass 模式也不能绕过。 这个设计的意图是:用户显式配置了"这类操作必须问我", 即使在最宽松的模式下也应该尊重这个意愿。

Step 1g——安全检查(safetyCheck),即使在 bypassPermissions 模式下也不可覆盖。 涉及 .git/.agent/.vscode/、shell 配置文件等敏感路径时硬性拦截。 这些路径有一个共同特征:修改它们可以导致代码执行。 .gitconfig 能设置 core.sshCommand 执行任意命令; .bashrc 在每次终端启动时运行; .agent/settings.json 可以修改权限规则本身。 "修改配置就等于获得执行权限"的路径,必须硬性保护。

9.4 Layer 2: 规则引擎——八种来源,一套优先级

如果工具自检没有给出明确判断,流程进入规则引擎阶段。 这一层做两件事: 检查当前权限模式是否允许放行,以及检查是否有全局 allow 规则。

规则的数据结构在权限类型定义模块中描述。 一条权限规则包含三个维度: source(来自哪里)、ruleBehavior(allow/deny/ask)、ruleValue(匹配什么)。

ruleValuetoolName 和可选的 ruleContent 组成。 toolName 匹配工具名(如 Bash), ruleContent 匹配工具特定的内容(如 git * 匹配 git 开头的命令)。 对于 MCP 工具,还支持服务器级别的匹配: 规则 mcp__server1 可以匹配该服务器下的所有工具。

来源有八种,它们有明确的优先级层次:

来源优先级说明
policySettings最高企业管理策略,不可被覆盖
flagSettings特性门控配置
userSettings~/.agent/settings.json
projectSettings.agent/settings.json
localSettings.agent/settings.local.json
cliArg--allow-tool 命令行参数
command/allow-tool 运行时命令
session最低会话内临时规则

为什么设计这么多来源? 因为安全策略的制定者不止一个。 企业安全团队需要强制执行组织策略, 开发者需要按项目配置权限, 用户需要根据当前任务临时调整。 这八种来源构成了从"全局强制"到"临时灵活"的完整光谱。

核心原则是:deny 总是优先于 allow。 无论 allow 规则来自哪里,只要存在更高优先级的 deny 规则,操作就会被拒绝。 这个原则在 deny 检查和 allow 检查的调用顺序中得到体现 ——deny 检查永远先于 allow 检查。

如果工具自检返回了 passthrough, 在进入后续流程之前,它会被转换为 askpassthrough 的语义是"我没有意见", 但"没有意见"在安全上下文中应该被解读为"需要确认",而非"默认允许"。

9.5 五种权限模式:安全与效率的刻度盘

最终行为还取决于当前的权限模式。 权限模式是系统层面的"安全刻度盘",在权限模式定义模块中声明。 可以把它想象成汽车的驾驶模式——同一辆车,不同模式下操控感完全不同。

default(日常驾驶): 未匹配规则的操作都需用户确认。这是最安全的模式,适合日常交互。 用户对每一个新操作都有完全的可见性和控制权。

acceptEdits(半自动): 工作目录内的文件编辑自动允许,但 shell 命令、MCP 工具等仍需确认。 适合信任度较高但不想完全放手的场景。 颜色标记为 autoAccept,提示用户该模式比 default 宽松一级。

auto(自动驾驶): 用 ML 分类器代替用户做出大部分决策。 这是 Chapter 10 的主题——分类器如何判断安全性,以及出错时怎么办。 颜色标记为 warning(黄色),视觉上提醒用户这需要信任分类器的判断能力。

dontAsk(静默拒绝): 将所有 ask 转为 deny,绝不弹窗。 适合非交互式批处理环境——宁可拒绝也不能挂起等待。 在权限模块末尾对 ask 结果做最终变换。

bypassPermissions(全信任): 几乎所有操作自动允许。 但即使在这个最宽松的模式下,Step 1g 的安全检查仍然生效 ——.git/.agent/ 等路径依然受保护。 颜色标记是 error(红色),无声地警告用户它的风险等级。

这五种模式不是孤立存在的。 plan 模式可以和 bypassPermissions 组合: 当用户原本在 bypass 模式下进入 plan 模式时, plan 模式继承了 bypass 的宽松度。 这种组合设计避免了模式切换时的体验断裂。

9.6 不可变的权限上下文

所有权限状态汇聚在权限类型定义模块的 ToolPermissionContext 类型中。 这个类型的每个字段都标记了 readonly ——用 TypeScript 类型系统强制不可变性。

为什么不可变性如此重要?

因为权限状态是安全决策的基础。 如果某段代码在权限检查过程中修改了状态, 可能导致一个"检查时安全、执行时危险"的竞态条件。 这就是经典的 TOCTOU(Time-of-Check-Time-of-Use)漏洞。 不可变性从类型层面消除了这一整类问题。

三份规则表——alwaysAllowRulesalwaysDenyRulesalwaysAskRules ——各自独立存储。 这个设计看似冗余,实则避免了运行时根据行为类型过滤规则的开销。 在每次工具调用都需要执行权限检查的场景下, 这种以空间换时间的取舍是合理的。

shouldAvoidPermissionPrompts 字段标记了无头模式 ——后台 Agent、CI 环境、子 Agent 等无法弹出对话框的场景。 当这个标记为 true 时,所有 ask 决策都会被转为 deny, 除非 PermissionRequest Hook 先行介入给出了 allow 判断。 这意味着无头 Agent 的安全策略是:"如果没有人能批准,就默认拒绝"。

awaitAutomatedChecksBeforeDialog 字段标记了 coordinator 模式 ——在弹出用户对话框之前,先同步等待 Hook 和分类器的结果。 这和默认的异步竞赛模式形成对比,适用于自动化优先的场景。

9.7 竞赛而非串行:resolveOnce 的智慧

当权限决策最终落入 ask 分支且需要用户确认时, 系统并不是简单地弹出对话框等待。 它同时启动多个竞赛方:

  • 用户对话框(UI 队列)
  • PermissionRequest Hook(用户自定义策略)
  • Bash 分类器(ML 判断)
  • 桌面端 Bridge(浏览器端确认)
  • 消息通道(Telegram、iMessage 远程确认)

哪个先返回结果,就用哪个。

这个竞赛通过权限上下文模块中的 createResolveOnce 实现。 核心是一个 claim() 方法——它是一个原子操作, 多个并发路径竞争,只有第一个调用 claim() 的会赢得决策权。 一旦某方 claim 成功,其他方的后续 resolve 调用都会被静默丢弃。

这个设计优雅地解决了一类微妙的竞态条件: 用户正在点"允许"的同一时刻,分类器恰好返回了结果。 没有 claim() 机制,两个决策都会被应用,导致不可预测的行为。

竞赛语义的另一个好处是性能: 如果分类器在 200ms 内给出高置信度判断, 用户甚至不需要看到权限弹窗。 体验上,快速操作像是"自动允许"了; 只有分类器也拿不准的操作才会真正弹窗。

在协调者处理路径中, 竞赛变为串行:先跑 Hook,再跑分类器,都不行才弹窗。 注释中的 (fast, local)(slow, inference) 暗示了设计者的意图 ——本地计算优先于远程推理,远程推理优先于人工确认。

9.8 设计哲学总结

回顾整个权限模型,四个设计原则贯穿始终:

Deny 优先。 在任何层级,deny 规则都优先于 allow 规则。 安全检查即使在最宽松的 bypass 模式下也不可覆盖。 这确保了安全底线不可被任何配置或模式组合突破。

纵深防御。 四层检查形成立体防线:工具自检捕获技术风险, 规则引擎实现策略控制,分类器提供智能判断,用户审批作为终极兜底。 任何单一层次的失败都不会导致整个系统的安全崩溃。

竞赛而非串行。 在需要用户确认的场景,多方同时启动,通过原子 claim 机制竞争。 效率最大化的同时保证决策唯一性。

不可变状态。 权限上下文通过 readonly 类型和不可变数据结构确保安全, 消除一整类由状态篡改导致的漏洞。


思考题

  1. bypassPermissions 模式下 safetyCheck 仍然生效, 但 auto 模式下 classifierApprovable 为 true 的 safetyCheck 可以交给分类器判断。 为什么这两种模式对 safetyCheck 的处理策略不同?

  2. 规则来源有八种之多,但 policySettingsflagSettings 的规则不可被删除(删除规则的函数会抛出异常)。 如果企业策略配置了一条错误的 deny 规则,用户只能等管理员修复吗? 设计一个紧急覆盖机制需要考虑哪些安全边界?

  3. claim() 机制保证了"第一个响应者获胜"。 但如果分类器以 300ms 的速度返回了错误的 allow 决策, 而用户本想点 deny,此时用户已经失去了否决权。 你会如何改进这个竞赛机制来平衡速度和安全?