Agent 的权限控制
前面的文章介绍了 shell、文件编辑和子 Agent。这些能力最终都会落到真实副作用上:启动进程、改写文件、访问网络,或者继续派生新的任务。模型可以提出操作,但不能因为模型提出了操作,系统就默认允许执行。
权限控制常被笼统地理解成“执行前弹窗”。源码中的边界要复杂一些。一套完整的控制至少包含三件事:
| 层次 | 要回答的问题 | 常见结果 |
|---|---|---|
| 策略 | 这次操作是否符合规则 | 放行、询问、拒绝 |
| 审批 | 需要确认时由谁决定 | 单次批准、持续批准、拒绝 |
| 隔离 | 即使获准,进程还能接触什么 | 只读、工作区可写、完全访问 |
审批不是沙盒,沙盒也不是业务授权。用户批准运行一条命令,不代表进程理应读取所有文件;进程被限制在工作区内,也不代表当前用户有权批准一笔费用。Microsoft Agent Framework、OpenCode 和 Codex 的主要差异,正是分别把这三层做到了什么程度。
MAF 把审批交给应用编排
MAF 是通用 SDK,不预设终端界面,也不替应用定义一套统一的权限配置。ChatClientAgent 的源码注释明确说明:提供给 Agent 的工具默认不经过用户批准,模型选择调用哪个函数,也选择传入哪些参数。因此,有副作用的工具必须由开发者主动加上审批要求。
最小入口是 ApprovalRequiredAIFunction。普通函数被它包装后,模型生成的 FunctionCallContent 不会立即触发函数执行,而是先变成 ToolApprovalRequestContent 返回给宿主。宿主可以在网页、桌面程序或命令行中展示请求,再把 ToolApprovalResponseContent 送回同一个 Session。
var tool = new ApprovalRequiredAIFunction(
AIFunctionFactory.Create(GetWeather));
AgentResponse response = await agent.RunAsync(
"What is the weather like in Amsterdam?",
session);
List<ToolApprovalRequestContent> requests = response.Messages
.SelectMany(message => message.Contents)
.OfType<ToolApprovalRequestContent>()
.ToList();
List<ChatMessage> approvals = requests.ConvertAll(request =>
new ChatMessage(ChatRole.User, [
request.CreateResponse(approved: true)
]));
response = await agent.RunAsync(approvals, session);
拒绝时只需把 approved 改为 false,还可以附带原因。这里的拒绝不是抛弃这次调用。FunctionInvokingChatClient 会把拒绝转换成模型可见的函数结果,随后模型可以解释无法继续的原因,或者改用别的方案。单元测试也验证了被拒绝的函数不会执行,而包含 rejected 信息的 FunctionResultContent 会进入下一次模型调用。
基础协议适合“一次请求,一次决定”。需要处理“不再询问”时,MAF 还有实验性的 ToolApprovalAgent。它包在内层 Agent 外部,把持续批准规则保存在 Session 状态中。规则分为两种:一种按工具名匹配,之后该工具的所有参数都可通过;另一种同时匹配工具名和完整参数,只有参数键、数量和序列化后的值都相同时才通过。后者可以避免批准一次低风险参数后,意外放开同一工具的高风险调用。
ToolApprovalAgent 还会对同一批未批准请求排队,每次只把一个请求交给调用方。已经命中规则的请求会自动生成批准响应,再交回内层 Agent。AllToolsAutoApprovalRule 虽然方便,但它等于关闭全部人工确认,只适合完全可信的环境,不能当作默认配置。
这里要注意两个边界。第一,审批规则依赖 Session 状态,是否跨进程持久化取决于宿主怎样保存 Session。第二,ApprovalRequiredAIFunction 解决的是人机确认,不是 RBAC、OAuth 或业务校验。即便用户点了批准,工具内部仍要检查当前身份、租户、额度和资源归属。
MAF 的主要源码入口如下:
src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cssrc/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgent.cssamples/02-agents/Agents/Agent_Step01_UsingFunctionToolsWithApprovals/Program.cstests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalsTests.cs
OpenCode 用有序规则决定是否询问
OpenCode 当前的工具执行链使用 V1 权限结构。每条规则包含权限名、匹配模式和动作,动作只有 allow、ask、deny 三种。没有命中任何规则时,默认结果是 ask。
配置可以给某项权限写一个统一动作,也可以按模式细分:
{
"permission": {
"*": "ask",
"bash": {
"*": "deny",
"git status *": "allow"
},
"edit": {
"src/*": "allow",
"src/secrets/*": "deny"
},
"external_directory": {
"~/projects/shared/*": "allow"
}
}
}
规则求值使用 findLast()。权限名和资源模式都支持通配符,在所有匹配项中,最后一条生效。这不是“越具体优先”,书写顺序本身就是优先级。上面的 src/secrets/* 必须放在 src/* 后面,否则更宽的允许规则会覆盖拒绝规则。配置解析也会特意保留属性原始顺序。
模型调用工具后,SessionTools.resolve() 创建 Tool.Context,其中的 ask() 会合并 Agent 权限与 Session 权限,再交给 Permission.Service.ask():
- 只要任意待检查模式命中
deny,整个请求立即拒绝。 - 所有模式都命中
allow,工具直接继续执行。 - 只要还有
ask,请求就进入 pending 集合,并发布permission.asked事件。
客户端收到事件后,可以回复 once、always 或 reject。once 只解除当前请求;always 把这次请求提供的通配模式加入进程内的 approved 数组,还会重新检查同一 Session 中其他 pending 请求,能匹配的请求会一起放行;reject 不仅拒绝当前请求,还会拒绝同一 Session 中其余待处理请求。若拒绝时带有消息,OpenCode 会把它包装为纠正反馈,让模型知道应该怎样调整。
这里的 always 容易被名字误导。当前执行链不会把它写回配置文件或数据库,它只在当前 OpenCode 实例的内存中有效,重启后便消失。需要跨重启保留的规则,仍要明确写进配置。
权限由工具主动申请,而不是权限服务猜测工具将做什么。文件写入、编辑和补丁工具会在产生副作用前申请 edit。shell 工具先用 tree-sitter 解析命令,收集命令模式和涉及的外部目录。如果命令会访问工作区外路径,它先申请 external_directory,再申请 bash。这两项权限含义不同:前者只允许跨越工作区边界,后者才决定命令是否可以执行。
OpenCode 这套机制是应用层的执行开关,不是 OS 沙盒。shell 最终通过 ChildProcess.make() 在宿主机创建进程。规则可以阻止一条命令启动,却不能在进程启动后用内核机制限制它能读取哪些目录。因此,允许范围过宽的 bash 规则仍然有很大风险。
OpenCode 的主要源码入口如下:
packages/opencode/src/permission/index.tspackages/opencode/src/session/tools.tspackages/opencode/src/tool/shell.tspackages/opencode/src/tool/external-directory.tspackages/core/src/v1/config/permission.ts
Codex 把审批与沙盒分成两层
Codex 面向本地编码场景,命令会直接接触源码、Git 和开发环境,所以它没有把“用户批准”当作最后一道边界。源码中的执行流程先计算审批要求,再选择沙盒运行;只有策略允许且执行环境也允许,命令才真正获得相应能力。
approval_policy 决定哪些请求可以交给用户:
| 配置值 | 实际含义 |
|---|---|
untrusted | 只有已知安全且只读的命令自动通过,其他命令需要确认 |
on-request | 默认模式,模型或执行流程按需发起审批,普通命令通常先在受限环境运行 |
granular | 分别控制沙盒升级、规则、Skill、权限请求和 MCP elicitation 等审批流 |
never | 不向用户请求升级,无法执行时直接把失败返回模型 |
on-failure 仍可作为反序列化兼容别名,但规范名称是 on-request。它也不表示“所有命令失败后自动弹窗”。普通退出码、超时和一般命令错误不会自动触发无沙盒重试;只有执行器识别出的沙盒拒绝,并且当前工具和策略允许升级时,才可能继续申请权限。
approval_policy = "on-request"
sandbox_mode = "workspace-write"
规则引擎 execpolicy 会把命令判断为 allow、prompt 或 forbidden。规则文件位于 $CODEX_HOME/rules/*.rules,使用 Starlark 语法,不是 TOML 或 JSON。前缀模式按拆分后的命令参数匹配,不应把整条命令写成一个字符串。
prefix_rule(
pattern = ["git", "status"],
decision = "allow",
)
prefix_rule(
pattern = ["rm"],
decision = "forbidden",
justification = "destructive command",
)
network_rule(
host = "packages.example.com",
protocol = "https",
decision = "prompt",
)
多条规则同时命中时,Codex 不是采用“最后一条覆盖”。决策会按严重程度合并,forbidden 高于 prompt,prompt 高于 allow。命令没有命中规则时,还会结合审批策略与内置的安全判断计算 ExecApprovalRequirement,最终得到 Skip、NeedsApproval 或 Forbidden。
需要审批时,Session::request_approval() 会先经过权限 Hook,再根据配置交给自动审查器或用户。客户端回复的决定不只包含批准和拒绝,还可以批准到当前 Session,或者接受一条建议的 execpolicy 修订。后者会调用 blocking_append_allow_prefix_rule(),把允许规则追加到 $CODEX_HOME/rules/default.rules,同时更新内存策略。也就是说,Codex 的永久批准不是一个模糊的“记住选择”,而是生成一条可以检查和修改的规则。
审批完成后,ToolOrchestrator 才选择执行环境。用户配置中常用的 sandbox_mode 有三种:
| 模式 | 文件系统边界 |
|---|---|
read-only | 文件系统只读 |
workspace-write | 允许写工作区及策略包含的额外目录 |
danger-full-access | 不施加文件系统限制 |
协议层还有 external-sandbox,用于表示进程已经处于外部隔离环境中,但它不是普通 sandbox_mode 的可选值。不同平台会选择不同后端,包括 macOS Seatbelt、Linux seccomp 与 bubblewrap,以及 Windows 受限令牌。workspace-write 也不是字面上的“只能写仓库”,实现还可能包含临时目录和显式配置的 writable roots,判断边界时要看最终生成的权限 Profile。
一次 shell 调用的核心路径可以概括为:
require_escalated 也不保证命令一定脱离沙盒。当权限 Profile 中存在必须保留的拒读约束时,重试仍可能使用沙盒。这个细节说明审批和隔离是两个独立判断:用户同意扩大某项能力,并不意味着系统要撤掉全部限制。
Codex 的主要源码入口如下:
codex-rs/protocol/src/protocol.rscodex-rs/core/src/exec_policy.rscodex-rs/core/src/tools/approvals.rscodex-rs/core/src/tools/orchestrator.rscodex-rs/core/src/tools/sandboxing.rscodex-rs/sandboxing/src/manager.rscodex-rs/execpolicy/src/parser.rscodex-rs/execpolicy/src/amend.rs
三套实现的边界
把源码放在一起看,三者并不是同一套权限系统的不同语法。MAF 交付的是可嵌入应用的审批协议,OpenCode 交付的是 CLI 应用中的规则与交互流程,Codex 则进一步把命令规则、审批和操作系统隔离接到同一条执行链上。
| 维度 | MAF | OpenCode | Codex |
|---|---|---|---|
| 未显式配置时 | 普通工具默认自动执行 | 未命中规则默认询问 | 由审批策略、规则与沙盒共同决定 |
| 规则粒度 | 工具名或工具名加精确参数 | 权限名加通配模式 | 命令前缀、网络规则与权限 Profile |
| 规则优先级 | 命中任一持续批准规则即可 | 最后一条匹配规则生效 | 按决策严重程度合并 |
| 单次批准 | 支持 | 支持 | 支持 |
| 会话内批准 | 通过 Session 规则支持 | always 保存在实例内存中 | 支持 ApprovedForSession |
| 持久批准 | 由宿主持久化 Session | 需要手工写配置 | 可写入 execpolicy 规则文件 |
| 拒绝后的处理 | 作为函数结果返回模型 | 拒绝同 Session 的待处理请求,可附纠正反馈 | 返回工具拒绝,也可终止当前任务 |
| OS 级命令沙盒 | 通用审批链本身不提供 | 不提供 | 提供 |
| 业务授权 | 工具内部自行实现 | 工具或服务内部自行实现 | 工具或服务内部自行实现 |
设计自己的 Agent 时,可以直接从这张边界表反推需求。SDK 至少要提供可暂停和恢复的审批协议,否则宿主无法接入自己的 UI 与身份系统;本地 CLI 需要可读的声明式规则,否则用户只能反复批准相同操作;会执行任意 shell 的编码 Agent 还需要独立于审批的强制隔离,因为提示词和确认按钮都不能约束一个已经启动的宿主进程。
最后,任何持续批准都应该尽量窄。按完整参数批准比按工具名批准更安全,git status 前缀比 git 前缀更安全,允许一个工作区比允许整个用户目录更安全。权限系统真正要保护的,不是“有没有弹过窗”,而是模型最终获得的能力是否恰好够用。