先从一个很普通的动作开始:模型想跑测试。它返回了一个 shell tool call,命令看起来也没问题。 但真正执行之前,Codex 还要知道这轮的工作目录是什么、当前允许读写哪些路径、网络是否受限、 用户配置的是遇到风险再问,还是永远不问,以及有没有 hook 想在工具执行前改写或拦住这次请求。

这些判断合起来,就是这一篇要讲的“权限”。这里的权限不是 UI 上的一个按钮,也不是 prompt 里一句“请谨慎执行”。它是一条运行时路径:turn 带入策略,registry 先跑 hook, orchestrator 做审批和 sandbox 选择,具体 runtime 只在某个 SandboxAttempt 里执行动作。

本文里的“副作用”指会碰外部环境的动作:启动进程、写文件、扩大可读写范围、访问网络, 或者把某个失败的 sandboxed attempt 升级为更宽的执行方式。读权限代码时,先问这个动作会不会产生副作用, 再看它是否进入 ToolOrchestrator

证据边界。 本文只描述 openai/codex 公开源码里能看到的策略字段、hook 调用、审批事件、sandbox 选择和 retry 逻辑。 文中把 “门禁”“关口”“收口” 作为阅读辅助词,对应源码里的 AskForApprovalPermissionProfileExecApprovalRequirementToolOrchestratorSandboxAttempt 等结构。 不推断私有 guardian 模型的内部判断,也不推断操作系统 sandbox 的未公开实现细节。

这一篇按六个问题往下拆:

  1. 一次 turn 从入口带进来哪些权限和 sandbox 参数?
  2. 这些参数进入运行时后,会落到哪些对象上?
  3. 工具真正执行前,pre/post hooks 和 permission hooks 分别在什么位置?
  4. ToolOrchestrator 怎样决定跳过、拒绝,还是请求审批?
  5. 初次 sandbox attempt 和失败后的 retry 是怎样选择的?
  6. 读源码时怎样区分“模型请求了动作”和“运行时允许动作发生”?

一、权限先从 turn 入口进入

Codex 的权限边界不是到 shell handler 才突然出现的。app-server v2 的 TurnStartParams 里已经能看到入口参数:approval_policy 可以覆盖本轮审批策略, sandbox_policy 可以覆盖本轮 sandbox,permissions 可以选择一个命名权限 profile, 而且注释明确写着 permissions 不能和 sandboxPolicy 同时使用。

这一步很重要。权限从这里就进入 turn runtime,和本轮的工作目录、workspace roots、 环境选择、模型参数放在同一层配置里;后面的工具只是读取这份边界。

到 core 层后,TurnContext 保存了 approval_policypermission_profilenetwork 和 Windows sandbox level。 它还提供 file_system_sandbox_policy()network_sandbox_policy(), 从 PermissionProfile 拆出文件系统和网络两类运行时策略。

入口字段 运行时对象 读源码时的含义
approval_policy AskForApproval 决定默认什么时候请求审批,什么时候直接返回失败。
sandbox_policy SandboxPolicy 旧的 sandbox 形状:full access、read-only、workspace-write 等。
permissions PermissionProfile 更细的权限 profile,会投影成文件系统和网络运行时策略。
runtime_workspace_roots workspace roots 决定 workspace-write、额外可写根等策略的解释范围。

如果某一轮更新了权限,session 配置会把这些更新合并进 thread state。 apply_updates 先处理新的 approval policy,再处理 permission profile 或 legacy sandbox policy。 选择 permission profile 时,它会调用 set_permission_profile_projection; 选择 legacy sandbox policy 时,则会重新构造文件系统和网络 policy,并写回 permission profile state。

二、四个词先分清

读到这里,容易把 approval、permissions、sandbox、hooks 全混在一起。它们确实都和安全边界有关, 但源码里的位置不同。

源码抓手 负责的问题 不负责的问题
审批策略 AskForApproval 这次动作要不要向用户、guardian 或 hook 要一个决定。 具体怎样隔离文件系统和网络。
权限 profile PermissionProfile 当前 turn 允许哪些文件系统、网络和额外权限形状。 模型是否会选择某个工具。
执行审批要求 ExecApprovalRequirement 某一次 tool request 是 skip、needs approval,还是 forbidden。 工具结果怎样回到模型。
sandbox attempt SandboxAttempt 一次实际执行使用的 sandbox 类型、权限 profile、cwd、workspace roots 和网络限制。 审批 UI 怎么展示。
hook pre/post/permission hooks 在工具执行前后或审批前插入外部规则。 替代 orchestrator 的 sandbox 选择。

对照源码看会更清楚。 AskForApproval 包含 UnlessTrustedOnFailureOnRequestGranularNever。其中 GranularApprovalConfig 还把 sandbox approval、execpolicy rules、 skill approval、request_permissions 和 MCP elicitation 分开控制。

旁边的 SandboxPolicy 则描述执行限制:danger-full-accessread-onlyexternal-sandboxworkspace-write,以及各自的网络和可写目录配置。审批策略回答“要不要问”,sandbox policy 回答“在什么限制下跑”。

三、registry 先给工具一次前置检查机会

第四篇已经讲过 ToolRegistry 是工具派发壳。放到权限主题里,它的第一个作用是: 在 handler 真正执行前,先跑 pre-tool-use hooks。 这段逻辑 会从工具拿 pre_tool_use_payload,调用 run_pre_tool_use_hooks。 hook 可以直接阻止这次工具调用,也可以返回 updated input,让 registry 用改写后的 invocation 继续往下走。

这不是审批弹窗。它发生在 handler 之前,更像一个组织或扩展层面的“工具入口规则”: 有些命令可以在这里被拒绝,有些输入可以在这里被规范化。真正的用户审批和 sandbox selection 还在后面的 orchestrator。

handler 成功后,registry 还有 post-tool-use hooks。 post hook 可以追加上下文,也可以用 feedback message 替换模型可见输出。也就是说,hook 不只管“能不能执行”, 还可能影响模型下一步看到什么。

这一层要和后面的 permission request hooks 区分开:pre/post hooks 包住工具 handler; permission request hooks 则包住审批请求,在 guardian 或用户审批之前抢先给出 allow / deny。

四、有副作用的工具进入 orchestrator

到这里,工具还没有真正碰系统。以 shell 为例,handler 构造 ShellRequest 后, 创建 ToolOrchestratorShellRuntime,再调用 orchestrator.run(...)apply_patch 也类似: 构造 ApplyPatchRequest 后进入同一个 orchestrator

这解释了为什么第四篇说“工具是运行时合约”。模型发出的只是 call;有副作用的 call 会被翻译成 runtime request, 然后交给统一的 orchestrator。真正的边界集中在 orchestrator.rs 文件头 已经写明的三件事:approval、sandbox selection、retry semantics。

这套统一入口来自 ApprovableSandboxableToolRuntime。 工具 runtime 可以提供审批 key、sandbox permissions、审批 payload、是否允许失败后升级、真正的 run 方法。 orchestrator 不需要知道每个工具的业务细节,只要按这些 trait 问问题。

继续固定一条请求:模型想在当前 workspace 里跑 npm test。进入 orchestrator 之后, 读源码可以按下面这条 record 追,而不是把审批、sandbox 和 retry 混成一个概念:

{
  "tool": "shell",
  "input": {"cmd": "npm test", "cwd": "workspace"},
  "approval_policy": "on-request",
  "sandbox_attempt": {
    "type": "workspace-write",
    "permission_profile": "project-write",
    "workspace_roots": ["workspace"]
  },
  "retry_reason": "sandbox_denied"
}

这个 shape 能把几层边界拆开:approval policy 决定先问不问,sandbox attempt 决定第一次在哪个权限 profile 下跑,tool runtime 负责把 attempt 变成实际执行环境; 失败如果像 sandbox denial,orchestrator 才按策略走升级或重试。

五、第一道门:这次需要审批吗

ToolOrchestrator::run 的第一步就是 approval。 源码先拿到本轮的文件系统和网络 sandbox policy, 然后问工具有没有自定义 exec_approval_requirement(req)。 如果没有,就调用 default_exec_approval_requirement

默认规则很直观:NeverOnFailure 默认不问; OnRequestGranular 在文件系统受限时需要审批; UnlessTrusted 总是问。Granular 还能直接把不允许的审批类别变成 Forbidden,这样请求不会被展示给用户,而是被运行时拒绝。

一旦得到 ExecApprovalRequirement,orchestrator 只处理三种结果: Skip 表示当前规则允许继续;Forbidden 直接返回 rejected; NeedsApproval 则创建 ApprovalCtx,把 call id、turn、retry reason、 network approval context 和可能的 guardian review id 带进审批请求。

approval 决策骨架:
  tool.exec_approval_requirement(req)
      或 default_exec_approval_requirement(policy, fs_policy)
        ↓
  Skip / Forbidden / NeedsApproval
        ↓
  允许继续 / 直接拒绝 / request_approval(...)

六、审批请求前,permission hooks 可以先回答

request_approval 里还有一个容易忽略的前置点: PermissionRequest hooks 优先处理审批请求。 只要当前路径允许评估 hook,而且工具提供了 permission_request_payload(req), Codex 会先调用 run_permission_request_hooks

hook 返回 Allow,这次审批直接变成 approved;返回 Deny, orchestrator 直接把 message 作为 rejected 原因;没有匹配结果,才落到正常的 guardian 或用户审批路径。 shell runtime 会把命令作为 bash payload 交给 permission hook; 源码里能看到它返回 PermissionRequestPayload::bash。 apply patch runtime 也会用 apply_patch hook name 提供 patch 命令内容。

这里的顺序很关键:permission hook 不是工具执行前的普通 hook,也不是执行后的反馈 hook。 它专门挂在审批请求之前,用来让配置或扩展先给出 allow / deny。只有 hook 没有回答时,才进入用户或 guardian。

七、第二道门:初次 sandbox attempt

审批通过以后,orchestrator 才会选择初次 sandbox。 这段代码 会先算 sandbox_override_for_first_attempt,再根据 override 和当前 policy 调 SandboxManager::select_initial。最后组装 SandboxAttempt: sandbox 类型、permission profile、是否启用 managed network、cwd、workspace roots、平台相关 sandbox 参数都会放进去。

SandboxAttempt 的定义在 sandboxing.rs。 它还提供 env_for,把 SandboxCommand 和当前权限 profile 交给 sandbox manager transform, 得到真正用于执行的 ExecRequest

shell runtime 的 run 里就能看到这一步的落点: 它从 attempt 取 runtime permissions,构造 sandbox command,再调用 attempt.env_for(...) 执行apply_patch runtime 也会从 attempt 生成文件系统 sandbox context;如果失败看起来像 sandbox denied, 会返回 SandboxErr::Denied

所以 sandbox 不是“工具执行失败后才补上的安全网”。它是每一次 attempt 的执行环境。 工具 runtime 拿到的是 SandboxAttempt,再把自己的命令或 patch 放进这个 attempt。

八、失败后能不能 retry,要再过一遍规则

如果初次 attempt 成功,orchestrator 直接返回 output。真正复杂的是 sandbox denied。 retry 分支 会先看 denial 里有没有网络策略信息,再看工具是否允许 escalate_on_failure(), 当前文件系统策略是否允许无 sandbox 执行,以及 approval policy 是否允许为了 no-sandbox retry 再问一次。

这段逻辑里有几个细节很能体现 Codex 的谨慎: 如果 denial 带有网络策略,但无法构造 network approval context,就直接返回 denied; 如果工具不允许失败升级,也直接返回 denied; 如果文件系统策略里有 denied-read restrictions,unsandboxed_execution_allowed 会阻止直接去掉 sandbox, 因为那些 denied reads 只有在 sandbox 里才能被执行。

需要 retry 时,orchestrator 会构造新的 retry reason,例如网络 host 被 policy 阻止,或者命令需要“retry without sandbox”。 它可能再次调用 request_approval,这次 permission request run id 会带上 :retry。 审批通过后,才会选择 retry sandbox:如果允许无 sandbox,就用 SandboxType::None; 否则仍然通过 select_initial 选择一个可表达当前限制的 sandbox。

sandbox denied 后:
  denial output + network policy
      ↓
  tool 是否允许升级
      ↓
  当前策略是否允许 no-sandbox 或网络审批
      ↓
  可能再次 request_approval(call_id:retry)
      ↓
  retry attempt 或把 denied 返回给模型

九、审批事件怎样交给客户端

当审批真的要展示给客户端时,protocol 里有清晰的事件形状。 ExecApprovalRequestEvent 包含 call id、approval id、turn id、命令、cwd、reason、network approval context、 proposed execpolicy amendment、additional permissions 和 available decisions。 ApplyPatchApprovalRequestEvent 则包含 patch changes、reason 和 grant root。

shell runtime 在 start_approval_async 中调用 session.request_command_approval;apply patch runtime 在 start_approval_async 中调用 session.request_patch_approval。 用户或 guardian 的返回值最后会落成 ReviewDecision

这一步也解释了客户端为什么能显示“批准本次”“本会话允许”“拒绝”等不同选择: 客户端拿到的是带有可用 decision、网络上下文、额外权限和未来规则建议的结构化请求。

十、读这条链时,带走三条规则

看到现象 源码里先问 关键位置
模型请求执行命令。 handler 是否把它翻成有副作用 runtime request? shell/apply_patch handler 调用 ToolOrchestrator
工具被问审批。 审批来自默认 policy,还是工具自定义 requirement? exec_approval_requirementdefault_exec_approval_requirement
审批没有弹给用户。 是不是 permission request hook 已经 allow / deny? request_approval 里的 run_permission_request_hooks
命令进了 sandbox。 本次 attempt 的 sandbox、permissions、cwd 和 network 是什么? SandboxAttempt
sandbox denied 后重试。 它是网络审批、no-sandbox retry,还是直接把 denied 返回? ToolOrchestrator::run retry 分支。

到这里,权限和 sandbox 的主线可以连起来了:turn 入口带入审批和权限策略; TurnContext 把它们保存成运行时对象;registry 在工具入口前后提供 hook; 有副作用的 handler 把请求交给 ToolOrchestrator; orchestrator 先判定审批,再选择 sandbox attempt,失败后按 policy 决定能不能 retry。

这也是读 Codex agent 时最容易错过的一点:模型提出动作,只是流程开始。 真正决定动作能不能碰系统的,是这条运行时路径。下一篇继续往外看:这些结构化事件和历史记录, 怎样被 TUI、app-server 和恢复逻辑投影成用户看到的同一套事实。

参考源码