审批、Permission Profile 与沙箱各自拦什么
沿固定版本 Codex 的 shell 控制面,拆开 AskForApproval、exec-policy requirement、ReviewDecision、PermissionProfile 与平台沙箱,解释批准之后为何仍可能留在隔离中运行。
第 16 章停在一个很容易误读的位置:handler 已经拿到了可以交给执行层的 command,但进程还没有启动。此后发生的事常被压成一句“需要审批就弹窗,通过后进沙箱”。源码里没有这样一条单线流程。
第 16 章交来的三种形态要继续分开:A 是实际执行 envelope,也就是 handler argv 连同 cwd、env 等运行输入;B 是 commands_for_exec_policy(A) 得到的 policy projection,可能把一段 shell argv 分成多个 command segments;C 是独立的 approval cache identity,由 shell runtime 另做 canonicalization。B 进入 ExecPolicyManager,C 只在普通用户审批路径查 session cache。C 不进入 exec-policy,三者也不能画成 A→B→C→policy。
commands_for_exec_policy 先尝试从 shell -lc 解析 plain commands;Windows build 还会尝试 PowerShell words;heredoc executable prefix 只能形成一个 used_complex_parsing: true 的候选;都不命中时才以原 argv 作为单段 projection。这里改变的是 policy 的观察粒度,不是将要 spawn 的 argv。
先把四层类型放回各自的位置
这套控制面至少有四层。它们都带“权限”意味,却回答不同问题:
| 层 | 代表类型 | 回答的问题 | 不负责什么 |
|---|---|---|---|
| prompt eligibility | AskForApproval | 某类请求能不能发起 prompt | 不给某次命令签发批准 |
| per-call requirement | ExecApprovalRequirement | 当前调用是 Skip、NeedsApproval 还是 Forbidden | 不选择 macOS/Linux/Windows backend |
| review result | ReviewDecision | 这一次审批通过、拒绝、中止,还是记住到 session | 不编译 filesystem/network 隔离规则 |
| enforcement input | concrete/effective PermissionProfile + SandboxType | 实际允许哪些访问,以及本机用哪个 backend 承载 | 不替用户做审批决定 |
因此,批准不等于解除沙箱。Approved 只让 orchestrator 继续走 first attempt;是否绕过 sandbox 还要看 requirement 里的 bypass_sandbox、per-call SandboxPermissions、denied-read 限制和 SandboxManager 的平台选择。
Granular 的 baseline 是五个开关
固定 tag 的 core AskForApproval 只有四个 variant:wire 上叫 untrusted 的 UnlessTrusted、默认的 OnRequest、Granular、Never。on-failure 只是 OnRequest 的 serde alias,不是第五种当前策略。
GranularApprovalConfig 的 baseline 就是五个字段。只有 skill_approval 与 request_permissions 带 serde default false;其余三个在 wire shape 里必填。
| 字段 | 缺省值 | 控制的 prompt 类别 |
|---|---|---|
sandbox_approval | 必填 | inline additional permissions 与 require_escalated |
rules | 必填 | exec-policy prompt rule |
skill_approval | false | skill script execution |
request_permissions | false | request_permissions tool |
mcp_elicitations | 必填 | MCP elicitation |
每个 bool true 只允许进入对应 prompt,不代表自动批准;false 的语义是请求自动被拒绝,不把它显示给用户。
sandbox_approval 管 inline with_additional_permissions 与 require_escalated 请求;rules 管显式 exec-policy prompt rule;skill、request-permissions tool 和 MCP elicitation 各走自己的 gate。app-server v2 会把 Granular 映射成同样五个字段,但仍标成 experimental。这是协议成熟度提示,不改变 core baseline。
PermissionProfile 才是运行时权威值
PermissionProfile 的 actual variants 是三种。旁边的 ActivePermissionProfile 不是第四个 variant,而是显示来源的 sidecar:
| 类型 / variant | 运行时地位 | 含义 |
|---|---|---|
Managed | runtime policy | Codex 构造 filesystem/network 隔离 |
Disabled | runtime policy | 不加 outer sandbox |
External | runtime policy | filesystem 隔离由外部调用方负责 |
ActivePermissionProfile | provenance sidecar | 只记录 id 与 extends |
前三行的结构体内容才是 conversation、turn 或 command 的 canonical runtime permissions。
旁边的 ActivePermissionProfile { id, extends } 只是 provenance,runtime 不能从名称反推权限。它让客户端稳定显示 :workspace 或用户 profile id,以及可选的 parent;真正执行仍必须服从已经编译好的 PermissionProfile。
一个细节尤其重要:Managed 加上 filesystem Unrestricted 不等于 Disabled。from_runtime_permissions_with_enforcement 只有在 enforcement 明确是 Disabled 时才产出 PermissionProfile::Disabled;同样的 unrestricted filesystem shape 在 managed enforcement 下仍是 Managed。这会影响后面是否仍需承载 network enforcement、managed requirements 或平台 wrapper。
SandboxPermissions 又是另一层。它的 UseDefault、RequireEscalated、WithAdditionalPermissions 描述单次 tool call 的 request intent,不是最终授权。RequireEscalated 请求绕过默认 sandbox;WithAdditionalPermissions 请求留在 sandbox 内局部扩权;两者都还要过 approval、policy validation 和 effective-profile materialization。
Exec-policy 先给当前调用判三态
这里没有一个同时替 classic shell 和 Unified Exec 算 requirement 的“统一 handler”。两条入口分别拥有这一步,但都调用同一个 ExecPolicyManager::create_exec_approval_requirement_for_command:
| 执行入口 | requirement 在哪里计算 | 计算后交给谁 |
|---|---|---|
| classic shell | tools/handlers/shell.rs 在构造 ShellRequest 之前 | ShellRuntime 再交给 ToolOrchestrator |
| Unified Exec | UnifiedExecProcessManager::open_session_with_sandbox 打开进程之前 | UnifiedExecRuntime 再交给 ToolOrchestrator |
两处都把 A 形态的 command、AskForApproval、权威 PermissionProfile、Windows sandbox level、per-call SandboxPermissions 和可选 prefix_rule 放进 ExecApprovalRequest。classic shell 在 handler 中计算;Unified Exec 的 handler 先解析命令与权限,真正到 process manager 打开 session 时才计算。runtime 只携带已经算好的 ExecApprovalRequirement,不会在 orchestrator 前再判一次。
ExecPolicyManager 随后生成 B 形态的 segments,对每段匹配显式规则;没有规则时才按 approval policy、permission profile、危险命令启发式和本次 sandbox request 计算 fallback。
多个 segments 的结果按 Decision 枚举的自然顺序聚合:Allow < Prompt < Forbidden,其中 Forbidden 是最严格结果。比如 cat file && touch other 不能因为第一段可读就整体 Allow;后段 Prompt 会把聚合结果抬到 Prompt,任何 Forbidden 又会盖过 Prompt。
聚合的 Decision 再映射为 ExecApprovalRequirement 三态:Skip 表示无需普通审批,NeedsApproval 表示可以发起这次审批,Forbidden 则当场拒绝。AskForApproval 在这里仍只是 prompt gate:一个 Prompt decision 可能因 Never 或 Granular 对应字段关闭而变成 Forbidden,不会跳成 Approved。
heuristic Allow 形成的 Skip 通常保留 bypass_sandbox: false,让命令依靠 sandbox 运行。只有每段都命中显式 allow rule 时,bypass_sandbox 才能是 true。即使如此,后面的 denied-read 检查仍可把 bypass 压回 NoOverride,避免通过 unsandboxed execution 丢掉 read deny 约束。
prefix_rule 和自动推导出的 amendment 都只是候选,只有 ReviewDecision::ApprovedExecpolicyAmendment 才会真正落规则;复杂 heredoc prefix 不允许自动推导,已有 prompt rule 也会阻止某些 amendment。这个 decision 会调用持久化路径,先尝试把 amendment 追加到磁盘规则,成功后再更新内存 policy;两者不是原子事务,普通 Approved 不会悄悄改规则。
审批发生在 sandbox 选择之前
下面这张图只画控制权,不展开 wrapper、PTY 或 process lifecycle。A/B/C 的节点彼此有独立用途:HANDLER 是 A,EXEC_POLICY 消费 B,C 只在普通用户 route 进入 cache。
flowchart TB
accTitle: Approval and sandbox control flow
accDescr: classic shell handler 或 Unified Exec process manager 先经 exec-policy 形成三态 requirement;NeedsApproval 才进入 permission-request hook,再分 Guardian 与普通用户 cache,决策通过后才选择 SandboxAttempt 并交给 ToolRuntime
HANDLER["A: actual execution envelope<br/>classic handler or Unified Exec manager"]
EXEC_POLICY["B: policy projection<br/>segments and strict aggregate"]
REQUIREMENT{"ExecApprovalRequirement"}
REJECT["reject before execution"]
ORCHESTRATOR["approval orchestrator"]
HOOK["permission-request hook"]
ROUTE{"approval route"}
GUARDIAN["Guardian review"]
CACHE["ApprovalStore lookup by C"]
C["C: independent approval cache identity"]
DECISION["ReviewDecision"]
UI["user approval UI"]
OVERRIDE["first sandbox override"]
ATTEMPT["concrete SandboxAttempt"]
RUN["ToolRuntime::run / Chapter 18"]
HANDLER --> EXEC_POLICY
EXEC_POLICY --> REQUIREMENT
REQUIREMENT -->|Skip| OVERRIDE
REQUIREMENT -->|Forbidden| REJECT
REQUIREMENT -->|NeedsApproval| ORCHESTRATOR
ORCHESTRATOR --> HOOK
HOOK -->|allow| DECISION
HOOK -->|deny| REJECT
HOOK -->|no decision| ROUTE
ROUTE -->|guardian| GUARDIAN
ROUTE -->|ordinary user route| CACHE
HANDLER -.-> C
C -.-> CACHE
CACHE -->|hit ApprovedForSession| DECISION
CACHE -->|miss| UI
UI --> DECISION
GUARDIAN --> DECISION
DECISION -->|Approved / ApprovedForSession / ApprovedExecpolicyAmendment| OVERRIDE
DECISION -->|NetworkPolicyAmendment: Allow| OVERRIDE
DECISION -->|Denied / Abort / TimedOut| REJECT
DECISION -->|NetworkPolicyAmendment: Deny| REJECT
OVERRIDE --> ATTEMPT
ATTEMPT --> RUN
ToolOrchestrator::run 接到的 request 已带 handler 算好的 requirement。它先处理三态:普通 Skip 记录 config decision 后继续;Forbidden 直接返回 rejected;NeedsApproval 才创建 approval context。strict auto-review 是一个显式例外:它甚至会让 Skip 接受 Guardian review,而且 sandboxed first attempt 已审过也不覆盖后续 unsandboxed retry,后者可能创建 fresh Guardian review。
request_approval 内部的真实次序是:permission-request hook 先尝试回答;hook 没给 decision,才按 guardian_review_id 分到 Guardian 或 tool.start_approval_async。后一个才是普通 user route。对 shell 与 Unified Exec,start_approval_async 内部先拿 C 查 with_cached_approval,cache miss 才发送 UI request。因此不能写成 policy→cache→hook;准确次序是 requirement 已定 → hook → Guardian 或普通 user route → 仅普通 user route 查 cache。
cache 只记一种 decision
ApprovalStore 把 serialized C identity 映射到 ReviewDecision。读取时只有所有 keys 都命中 ApprovedForSession 才跳过 prompt;写入时也只有本次结果就是 ApprovedForSession 才逐 key 保存。Approved 只批准当前请求,Denied 拒绝当前调用但 session 可继续,Abort 表示在用户下一条命令前不再动作;三者语义不能合并。
ApprovalStore 只缓存 ApprovedForSession,可跨 turn 复用但不跨 Session 或进程重启。store 属于 SessionServices 的内存状态,没有持久化协议。Guardian 与 hook 不会写通用 cache:hook 的 allow/deny 直接返回,Guardian 走 review service,二者都绕过普通 runtime 的 with_cached_approval。
ReviewDecision 还包含 ApprovedExecpolicyAmendment、network policy amendment 和 Guardian timeout。它们属于响应这次审批的结果类型,不改变前面 requirement 的三态。尤其是 amendment variant 同时表达“本次通过”和“把候选规则持久化”,这正是 prefix hint 与真正 policy update 的边界。
require_escalated 仍然只是一份请求
require_escalated 请求绕过默认 sandbox,但仍可能被 exec-policy 判为 Forbidden 或在 UI/Guardian 阶段得到 Denied,所以它不是最终批准或授权。它会让 unmatched restricted command 倾向 Prompt,也可能被 Granular 的 sandbox_approval: false 自动拒绝;命令获批之后,filesystem policy 仍有最后一道 denied-read 检查。
sandbox_override_for_first_attempt 先调用 unsandboxed_execution_allowed。只要 active filesystem policy 含 denied reads,就返回 NoOverride;sandbox_permissions_preserving_denied_reads 还会把 RequireEscalated 降回 UseDefault。这是有意保留 sandbox,因为 read deny 只有隔离层能执行。相对地,WithAdditionalPermissions 留在 sandbox 内局部扩权,overlay 只影响这个 command。
request_permissions 是另一条显式工具流程,可以简化成:先 normalize requested profile;审批响应只能授予请求的子集并选择 turn/session scope;结果进入 turn/session state,后续 shell request 再把已授予内容并入 effective additional permissions。它说明局部 grant 如何跨调用生效,但不替代本章的 approval-before-sandbox 主线。
denial 反馈最多再给一次机会
first attempt 的普通错误直接返回。只有 structured SandboxErr::Denied 才至多一次 retry:先检查 tool 是否允许 failure escalation、approval policy 是否允许对应 prompt、denied-read 是否允许 unsandboxed execution,再决定是否要求 retry approval。strict auto-review 对 retry 可能创建 fresh Guardian;若 denied-read 必须保留,retry 仍选择 sandbox,而不是假装升级成 unrestricted。
这个分支至多构造一个 second SandboxAttempt。怎样从 output 判断 sandbox denial、怎样启动进程、收集 stdout/stderr、维持 PTY 或 session,不在这里展开;它们属于第 18 章。本章只确认 structured denial 会把控制权交回审批面,而且不保证第二次一定 unsandboxed。
SandboxManager 选择的是平台承载方式
shared SandboxType 只有四个 variant:
| variant | host | 承载方式 |
|---|---|---|
None | 所有平台 | 当前 host 不使用 concrete wrapper |
MacosSeatbelt | macOS | Seatbelt profile |
LinuxSeccomp | Linux | bubblewrap filesystem view + seccomp |
WindowsRestrictedToken | Windows | restricted-token sandbox |
select_initial 先用 filesystem policy、network policy、tool preference 和 managed-network requirements 判断“是否需要 sandbox”,再按当前 host 选 concrete backend。Windows sandbox 被禁用,或运行在 unsupported host 时,platform lookup 可以得到 None。
LinuxSeccomp 的默认 helper 使用 bubblewrap 建 filesystem view,再叠加 seccomp,不能把这个名字读成“只有 seccomp”;Landlock 由 use_legacy_landlock 打开,是 helper 的 legacy mode,不是 shared variant。managed proxy network 需要 bubblewrap 的 isolated network namespace,所以即便打开 legacy flag,也可能仍走 bubblewrap 路径。
SandboxManager::transform 才把 command 的 additional permissions 与 base profile 合成最终 effective PermissionProfile 并物化 cwd/workspace roots,随后生成平台 command。overlay 复用 base profile 的 enforcement,不能把 Managed 改造成 Disabled,也不能借局部 grant 改写 conversation 的 base policy。
remote 环境传的是 portable intent,包括 canonical permissions、cwd 与 sandbox_requested;执行端再选择自己的平台 backend,而不是接收 orchestrator 本机已经包好的 Seatbelt 或 Linux argv。当前边界也很硬:远程 Windows 的 sandboxed process launch 不支持,exec-server 会明确拒绝;unsandboxed remote process 是另一种情况。
延伸练习:如果新增 network_approval,需要改哪些层
固定 tag 的 GranularApprovalConfig 没有 network_approval 字段;五字段 baseline 仍是 sandbox_approval、rules、skill_approval、request_permissions、mcp_elicitations。下面只把 network_approval 当作本地设计练习,不属于当前行为或固定版本行为。
如果真要新增它,改一个 Rust struct 远远不够。至少要同步检查:
- core serde shape:它是否必填,还是像
skill_approval一样 default false; - app-server v2 映射与 experimental metadata:双向
to_core/From不能漏字段; - config、Requirements、JSON schema 与生成的 TypeScript:旧客户端缺字段时必须有明确兼容语义;
- runtime branch:network denial 何时可发 prompt,false 时返回哪一种 structured rejection;
- tests:round-trip、missing-field、Granular gate、Guardian/user route 与 denial retry 都要各自覆盖。
这份练习不能拿来解释当前 network approval module。固定源码已经有 managed-network denial context 与 network policy amendment,但那不等于 Granular 已提供同名字段。
固定实验:四组稳定 approval matrix
archive 与 lockfile 的校准由本仓库的 scripts/codex-handbook-part3.sh 提供。本章代码块在自己的 shell 中 source helper、调用 prepare_codex_part3 并注册 cleanup trap;没有博客 checkout 时,把该 helper 文件保存到任意路径,再通过 CODEX_HANDBOOK_PART3_HELPER 指向它。SOURCE_ROOT 指向固定仓库根,SOURCE_DIR 指向其中的 codex-rs,ARCHIVE_DIR 是校准后的 disposable 副本根,编译产物只能写进 $ARCHIVE_DIR/target。共享准备会核对 132 个 workspace package 的完整 name set 和 lock diff,而不是只看数量。
固定源码还明确要求 Rust 测试经 just test 运行。完整 selector 会展开五个 generated case,但 Unified Exec case 有下面会单独解释的 initial-yield race;稳定 gate 因此显式跳过它。共享 runner run_checked_test 会检查 nextest 实际运行并通过其余 4 项测试,并拒绝 Skipping test... 或 skip_if_ 输出;环境里已经存在 sandbox marker 时,下面的前置断言直接失败,不会把 fixture 的提前返回记成绿色。
共享准备完成后,本章只增加 approval matrix 的 selector:
set -euo pipefail
SOURCE_ROOT="${SOURCE_ROOT:-/tmp/codex-handbook-final-rust-v0.144.6}"
SOURCE_DIR="$SOURCE_ROOT/codex-rs"
CODEX_HANDBOOK_PART3_HELPER="${CODEX_HANDBOOK_PART3_HELPER:-$PWD/scripts/codex-handbook-part3.sh}"
test -f "$CODEX_HANDBOOK_PART3_HELPER"
source "$CODEX_HANDBOOK_PART3_HELPER"
prepare_codex_part3
trap 'cleanup_codex_part3 "$?"' EXIT
ARCHIVE_CODEX_RS="$ARCHIVE_DIR/codex-rs"
export CARGO_TARGET_DIR="$ARCHIVE_DIR/target"
test -f "$SOURCE_DIR/Cargo.toml"
test "$CARGO_TARGET_DIR" = "$ARCHIVE_DIR/target"
cmp -s "$ARCHIVE_CODEX_RS/workspace.names" "$ARCHIVE_CODEX_RS/lock.names"
test -z "${CODEX_SANDBOX+x}"
test -z "${CODEX_SANDBOX_NETWORK_DISABLED+x}"
cd "$ARCHIVE_CODEX_RS"
run_checked_test approval 4 \
just test --locked -p codex-core --test all --no-capture \
-E 'test(approval_matrix_covers_group)' -- --skip unified_exec
不能把这条命令简写回裸 cargo test。早期直接运行 Cargo 时,五个 case 已经开始,但本机 Tokio worker stack overflow 并以 SIGABRT 退出;固定 justfile 会设置 8 MiB 测试线程栈并调用 nextest。CODEX_SANDBOX_NETWORK_DISABLED 又会触发 fixture 的 skip_if_no_network!,因此当前稳定 gate 的通过判定必须同时满足四个非 Unified Exec case 实际运行、4 项全部通过、没有环境 marker,也没有未捕获的 skip 文本。第五个 generated case 只保留在下面的诊断记录里,不参与稳定 gate。
诊断附录:Unified Exec 的 initial-yield timing race
下面这张表来自此前用旧 Cargo harness 做的调度诊断,不是上面 just / nextest 命令的输出。前 25 次绿色对照(exact 10 次、五组默认并发 10 次、五组串行 5 次)复用同一个新 archive、同一份校准 lockfile 与 archive-local target;表中后两行的独立复核与受控 CPU 压力来自后续独立运行。它只用来定位 initial-yield race,不参与当前共享 runner 的通过判定:
| 运行方式 | 重复次数 | 实际观测 |
|---|---|---|
unified_exec exact | 10 次 | 10/10 |
| 五组默认并发 | 10 次 | 10 次均为 5/5 |
| 五组串行 | 5 次 | 5 次均为 5/5 |
| 独立复核 | 1 次 | 4/5;unified_exec 返回 exit_code=None |
| 受控 CPU 压力 | 1 次(72 burners) | 1.0013s 后返回 session;exit_code=None |
失败 scenario 是 Unified Exec 的 safe command。fixture 把 yield-time-ms 固定为 1000 ms,却沿用要求 exit_code == Some(0) 的 strict success expectation。process manager 到 initial yield deadline 后刷新状态;如果进程仍被观察为 live session,响应允许带 stdout、session id 与 exit_code=None。换句话说,fixture 把合法的 first-yield running 状态断言成了 terminal completion。
受控诊断只改变 CPU 调度压力,没有改 fixture:36 个临时 burner 下测试仍通过,但组耗时从约 1.13 秒升到 1.95 秒;72 个 burner 下,单独 exact unified_exec 已触发 1.0013 秒后返回 session ID 1000 的形态。诊断结束会清理全部临时进程;这里不提供压力脚本,因为它不是读者复现实验的必要部分。
因此这是一条 low-frequency initial-yield timing race,不是 approval 语义失败,也不是五组之间的共享状态问题。旧 harness 的单次 5/5 不能当作稳定保证;诊断时使用的 --test-threads=1 只降低普通竞争,不能保证在 CPU 压力下跨过这个边界,也不是本章当前复现命令的一部分。
五个 generated case 仍来自 test_case 展开:danger-full-access、read-only、workspace-write、apply-patch 与 Unified Exec。它们证明固定 fixture 的组合结果,不证明所有平台 backend 都在这台 macOS 主机上实际启动过。

在 macOS 上,将源码固定到 commit 5d1fbf26c43abc65a203928b2e31561cb039e06d
后依次运行下面两条命令:
target/debug/codex --versiontarget/debug/codex sandbox -- /bin/sh -c 'printf "trace=%s\nsandbox=%s\nnetwork_disabled=%s\n" "TRACE_OK" "$CODEX_SANDBOX" "$CODEX_SANDBOX_NETWORK_DISABLED"'预期本地二进制报告 codex-cli 0.144.6,沙箱内子命令执行到 printf,并读出 CODEX_SANDBOX 与
CODEX_SANDBOX_NETWORK_DISABLED;实际输出为 codex-cli 0.144.6、trace=TRACE_OK、
sandbox=seatbelt 与 network_disabled=1。其中 TRACE_OK
是命令中的字面量,只证明子命令执行到该输出点;另外两个值只证明本次进程收到相应环境标记,不能证明所有文件、系统调用和网络请求都已被内核拦截,也不能外推
Linux、Windows 或其他 permission profile。
交给第 18 章的对象
第 18 章接到的是 orchestrator 已批准或无需审批的 concrete SandboxAttempt:它带 base/canonical permissions、SandboxType、sandbox_requested、cwd、workspace roots 与 network context。sandbox_requested 记录 policy intent,哪怕当前 host 最终没有 concrete wrapper;permissions 服务当前 host,exec_server_permissions 保留远端执行端物化前的 canonical profile。
orchestrator 构造 SandboxAttempt 后才调用 ToolRuntime::run。attempt 的 env_for / env_for_exec_server 把 command 送进 manager;remote path 传 native command 与 portable sandbox context,不把本机 wrapper 发过去。
单命令 overlay 的最终 effective PermissionProfile 在 SandboxManager::transform 时物化。第 18 章拥有 transform、launch、PTY、process、output 与 session lifetime;如果执行层返回 structured denial,它又把控制权交回本章描述的 retry 控制面。