先从一个很普通的动作开始:模型想跑测试。它返回了一个 shell tool call,命令看起来也没问题。 但真正执行之前,Codex 还要知道这轮的工作目录是什么、当前允许读写哪些路径、网络是否受限、 用户配置的是遇到风险再问,还是永远不问,以及有没有 hook 想在工具执行前改写或拦住这次请求。
这些判断合起来,就是这一篇要讲的“权限”。这里的权限不是 UI 上的一个按钮,也不是 prompt
里一句“请谨慎执行”。它是一条运行时路径:turn 带入策略,registry 先跑 hook,
orchestrator 做审批和 sandbox 选择,具体 runtime 只在某个 SandboxAttempt
里执行动作。
本文里的“副作用”指会碰外部环境的动作:启动进程、写文件、扩大可读写范围、访问网络,
或者把某个失败的 sandboxed attempt 升级为更宽的执行方式。读权限代码时,先问这个动作会不会产生副作用,
再看它是否进入 ToolOrchestrator。
证据边界。
本文只描述 openai/codex 公开源码里能看到的策略字段、hook 调用、审批事件、sandbox 选择和 retry 逻辑。
文中把 “门禁”“关口”“收口” 作为阅读辅助词,对应源码里的 AskForApproval、
PermissionProfile、ExecApprovalRequirement、
ToolOrchestrator 和 SandboxAttempt 等结构。
不推断私有 guardian 模型的内部判断,也不推断操作系统 sandbox 的未公开实现细节。
这一篇按六个问题往下拆:
- 一次 turn 从入口带进来哪些权限和 sandbox 参数?
- 这些参数进入运行时后,会落到哪些对象上?
- 工具真正执行前,pre/post hooks 和 permission hooks 分别在什么位置?
ToolOrchestrator怎样决定跳过、拒绝,还是请求审批?- 初次 sandbox attempt 和失败后的 retry 是怎样选择的?
- 读源码时怎样区分“模型请求了动作”和“运行时允许动作发生”?
一、权限先从 turn 入口进入
Codex 的权限边界不是到 shell handler 才突然出现的。app-server v2 的
TurnStartParams
里已经能看到入口参数:approval_policy 可以覆盖本轮审批策略,
sandbox_policy 可以覆盖本轮 sandbox,permissions 可以选择一个命名权限 profile,
而且注释明确写着 permissions 不能和 sandboxPolicy 同时使用。
这一步很重要。权限从这里就进入 turn runtime,和本轮的工作目录、workspace roots、 环境选择、模型参数放在同一层配置里;后面的工具只是读取这份边界。
到 core 层后,TurnContext
保存了 approval_policy、permission_profile、network 和 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
包含 UnlessTrusted、OnFailure、OnRequest、Granular 和
Never。其中 GranularApprovalConfig 还把 sandbox approval、execpolicy rules、
skill approval、request_permissions 和 MCP elicitation 分开控制。
旁边的
SandboxPolicy
则描述执行限制:danger-full-access、read-only、external-sandbox、
workspace-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 后,
创建 ToolOrchestrator 和 ShellRuntime,再调用 orchestrator.run(...)。
apply_patch 也类似:
构造 ApplyPatchRequest 后进入同一个 orchestrator。
这解释了为什么第四篇说“工具是运行时合约”。模型发出的只是 call;有副作用的 call 会被翻译成 runtime request,
然后交给统一的 orchestrator。真正的边界集中在
orchestrator.rs 文件头
已经写明的三件事:approval、sandbox selection、retry semantics。
这套统一入口来自
Approvable、Sandboxable 和 ToolRuntime。
工具 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。
默认规则很直观:Never 和 OnFailure 默认不问;
OnRequest 和 Granular 在文件系统受限时需要审批;
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_requirement 与 default_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 和恢复逻辑投影成用户看到的同一套事实。
参考源码
- openai/codex 固定源码快照
- TurnStartParams 的 approval_policy、sandbox_policy 与 permissions
- TurnContext 的 approval_policy、permission_profile 与 sandbox policy helper
- SessionConfiguration apply_updates 处理 approval、permission profile 与 sandbox policy
- AskForApproval
- GranularApprovalConfig
- SandboxPolicy
- ToolRegistry pre-tool-use hooks
- ToolRegistry post-tool-use hooks
- shell handler 进入 ToolOrchestrator
- apply_patch handler 进入 ToolOrchestrator
- ApprovalCtx、PermissionRequestPayload 与 ExecApprovalRequirement
- default_exec_approval_requirement
- sandbox_override_for_first_attempt 与 denied reads 保护
- Approvable、Sandboxable 与 ToolRuntime
- SandboxAttempt
- ToolOrchestrator 文件头说明
- ToolOrchestrator approval 阶段
- ToolOrchestrator 初次 sandbox attempt
- ToolOrchestrator sandbox denied retry
- request_approval 与 PermissionRequest hooks
- ShellRuntime approval 与 permission request payload
- ShellRuntime 使用 SandboxAttempt 执行
- ApplyPatchRuntime approval 与 permission request payload
- ApplyPatchRuntime 使用 SandboxAttempt 执行
- ExecApprovalRequestEvent
- ApplyPatchApprovalRequestEvent
- ReviewDecision