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,将整个四层决策流封装为异步操作。
如果调用方提供了强制决策结果(用于测试或已完成权限检查的场景),直接使用;
否则进入规则引擎的核心流程。
这个函数的返回值只有三种:allow、deny、ask。
简洁的三值语义让后续所有层的处理逻辑都清晰可控。
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(匹配什么)。
ruleValue 由 toolName 和可选的 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,
在进入后续流程之前,它会被转换为 ask。
passthrough 的语义是"我没有意见",
但"没有意见"在安全上下文中应该被解读为"需要确认",而非"默认允许"。
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)漏洞。 不可变性从类型层面消除了这一整类问题。
三份规则表——alwaysAllowRules、alwaysDenyRules、alwaysAskRules
——各自独立存储。
这个设计看似冗余,实则避免了运行时根据行为类型过滤规则的开销。
在每次工具调用都需要执行权限检查的场景下,
这种以空间换时间的取舍是合理的。
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 类型和不可变数据结构确保安全,
消除一整类由状态篡改导致的漏洞。
思考题
-
bypassPermissions模式下 safetyCheck 仍然生效, 但auto模式下classifierApprovable为 true 的 safetyCheck 可以交给分类器判断。 为什么这两种模式对 safetyCheck 的处理策略不同? -
规则来源有八种之多,但
policySettings和flagSettings的规则不可被删除(删除规则的函数会抛出异常)。 如果企业策略配置了一条错误的 deny 规则,用户只能等管理员修复吗? 设计一个紧急覆盖机制需要考虑哪些安全边界? -
claim()机制保证了"第一个响应者获胜"。 但如果分类器以 300ms 的速度返回了错误的 allow 决策, 而用户本想点 deny,此时用户已经失去了否决权。 你会如何改进这个竞赛机制来平衡速度和安全?