Agent 的权限控制

前面的文章介绍了 shell、文件编辑和子 Agent。这些能力最终都会落到真实副作用上:启动进程、改写文件、访问网络,或者继续派生新的任务。模型可以提出操作,但不能因为模型提出了操作,系统就默认允许执行。

权限控制常被笼统地理解成“执行前弹窗”。源码中的边界要复杂一些。一套完整的控制至少包含三件事:

层次要回答的问题常见结果
策略这次操作是否符合规则放行、询问、拒绝
审批需要确认时由谁决定单次批准、持续批准、拒绝
隔离即使获准,进程还能接触什么只读、工作区可写、完全访问

审批不是沙盒,沙盒也不是业务授权。用户批准运行一条命令,不代表进程理应读取所有文件;进程被限制在工作区内,也不代表当前用户有权批准一笔费用。Microsoft Agent Framework、OpenCode 和 Codex 的主要差异,正是分别把这三层做到了什么程度。

正在渲染 Mermaid 图表...

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.cs
  • src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgent.cs
  • samples/02-agents/Agents/Agent_Step01_UsingFunctionToolsWithApprovals/Program.cs
  • tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalsTests.cs

OpenCode 用有序规则决定是否询问

OpenCode 当前的工具执行链使用 V1 权限结构。每条规则包含权限名、匹配模式和动作,动作只有 allowaskdeny 三种。没有命中任何规则时,默认结果是 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()

  1. 只要任意待检查模式命中 deny,整个请求立即拒绝。
  2. 所有模式都命中 allow,工具直接继续执行。
  3. 只要还有 ask,请求就进入 pending 集合,并发布 permission.asked 事件。

客户端收到事件后,可以回复 oncealwaysrejectonce 只解除当前请求;always 把这次请求提供的通配模式加入进程内的 approved 数组,还会重新检查同一 Session 中其他 pending 请求,能匹配的请求会一起放行;reject 不仅拒绝当前请求,还会拒绝同一 Session 中其余待处理请求。若拒绝时带有消息,OpenCode 会把它包装为纠正反馈,让模型知道应该怎样调整。

这里的 always 容易被名字误导。当前执行链不会把它写回配置文件或数据库,它只在当前 OpenCode 实例的内存中有效,重启后便消失。需要跨重启保留的规则,仍要明确写进配置。

权限由工具主动申请,而不是权限服务猜测工具将做什么。文件写入、编辑和补丁工具会在产生副作用前申请 edit。shell 工具先用 tree-sitter 解析命令,收集命令模式和涉及的外部目录。如果命令会访问工作区外路径,它先申请 external_directory,再申请 bash。这两项权限含义不同:前者只允许跨越工作区边界,后者才决定命令是否可以执行。

正在渲染 Mermaid 图表...

OpenCode 这套机制是应用层的执行开关,不是 OS 沙盒。shell 最终通过 ChildProcess.make() 在宿主机创建进程。规则可以阻止一条命令启动,却不能在进程启动后用内核机制限制它能读取哪些目录。因此,允许范围过宽的 bash 规则仍然有很大风险。

OpenCode 的主要源码入口如下:

  • packages/opencode/src/permission/index.ts
  • packages/opencode/src/session/tools.ts
  • packages/opencode/src/tool/shell.ts
  • packages/opencode/src/tool/external-directory.ts
  • packages/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 会把命令判断为 allowpromptforbidden。规则文件位于 $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 高于 promptprompt 高于 allow。命令没有命中规则时,还会结合审批策略与内置的安全判断计算 ExecApprovalRequirement,最终得到 SkipNeedsApprovalForbidden

需要审批时,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 调用的核心路径可以概括为:

正在渲染 Mermaid 图表...

require_escalated 也不保证命令一定脱离沙盒。当权限 Profile 中存在必须保留的拒读约束时,重试仍可能使用沙盒。这个细节说明审批和隔离是两个独立判断:用户同意扩大某项能力,并不意味着系统要撤掉全部限制。

Codex 的主要源码入口如下:

  • codex-rs/protocol/src/protocol.rs
  • codex-rs/core/src/exec_policy.rs
  • codex-rs/core/src/tools/approvals.rs
  • codex-rs/core/src/tools/orchestrator.rs
  • codex-rs/core/src/tools/sandboxing.rs
  • codex-rs/sandboxing/src/manager.rs
  • codex-rs/execpolicy/src/parser.rs
  • codex-rs/execpolicy/src/amend.rs

三套实现的边界

把源码放在一起看,三者并不是同一套权限系统的不同语法。MAF 交付的是可嵌入应用的审批协议,OpenCode 交付的是 CLI 应用中的规则与交互流程,Codex 则进一步把命令规则、审批和操作系统隔离接到同一条执行链上。

维度MAFOpenCodeCodex
未显式配置时普通工具默认自动执行未命中规则默认询问由审批策略、规则与沙盒共同决定
规则粒度工具名或工具名加精确参数权限名加通配模式命令前缀、网络规则与权限 Profile
规则优先级命中任一持续批准规则即可最后一条匹配规则生效按决策严重程度合并
单次批准支持支持支持
会话内批准通过 Session 规则支持always 保存在实例内存中支持 ApprovedForSession
持久批准由宿主持久化 Session需要手工写配置可写入 execpolicy 规则文件
拒绝后的处理作为函数结果返回模型拒绝同 Session 的待处理请求,可附纠正反馈返回工具拒绝,也可终止当前任务
OS 级命令沙盒通用审批链本身不提供不提供提供
业务授权工具内部自行实现工具或服务内部自行实现工具或服务内部自行实现

设计自己的 Agent 时,可以直接从这张边界表反推需求。SDK 至少要提供可暂停和恢复的审批协议,否则宿主无法接入自己的 UI 与身份系统;本地 CLI 需要可读的声明式规则,否则用户只能反复批准相同操作;会执行任意 shell 的编码 Agent 还需要独立于审批的强制隔离,因为提示词和确认按钮都不能约束一个已经启动的宿主进程。

最后,任何持续批准都应该尽量窄。按完整参数批准比按工具名批准更安全,git status 前缀比 git 前缀更安全,允许一个工作区比允许整个用户目录更安全。权限系统真正要保护的,不是“有没有弹过窗”,而是模型最终获得的能力是否恰好够用。