# 权限模型 · 沙箱策略与审批策略

> Codex 的安全边界由两个独立的量决定：PermissionProfile 规定命令在技术上能碰什么，AskForApproval 规定越界之前要不要问人。本篇讲清两者的取值、旧的 sandbox_mode 与新的权限配置档如何编译成同一个 PermissionProfile、“最具体者胜”的路径裁决、.git 等元数据保护，以及管理员下发的 deny_read。

- 作者：David（道雾轩）
- 专栏：Codex 源码解读（https://daiw.net/manual/codex-source.md）
- 最后更新：2026-09-29
- 原文：https://daiw.net/manual/codex-source/permissions-model
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

# 权限模型 · 沙箱策略与审批策略

第 4 部分讲安全。后面七篇分别拆审批流程、三个平台的沙箱、网络代理、执行策略和自动审查，它们消费的都是同两个值：一个 `PermissionProfile`（能做什么），一个 `AskForApproval`（什么时候问）。这一篇先把这两个数据结构讲透。

## 用户看到的样子

启动时用 `-s`/`--sandbox` 选沙箱模式（`read-only`、`workspace-write`、`danger-full-access`），用 `-a`/`--ask-for-approval` 选审批策略（交互模式只接受 `on-request` 与 `never`）；`--approve-for-me` 只是三条配置覆盖的简写：`approvals_reviewer="auto_review"`、`approval_policy="on-request"`、`sandbox_mode="workspace-write"`（`codex-rs/utils/cli/src/shared_options.rs:80`）。`config.toml` 里有两套写法：旧的 `sandbox_mode` 加 `[sandbox_workspace_write]`，新的 `default_permissions` 加 `[permissions.<name>]` 配置档，内置 `:read-only`、`:workspace`、`:danger-full-access` 三个。各种组合的效果与安全建议见手册的[沙箱、审批与安全](https://daiw.net/manual/codex/sandbox-approvals)，本篇只看这些设置最后变成了什么。

## 两套表示，一个真身

旧表示是 `SandboxPolicy`（`codex-rs/protocol/src/protocol.rs:1072`），四个变体：`DangerFullAccess`、`ReadOnly`、`ExternalSandbox`、`WorkspaceWrite`，后者带 `writable_roots`、`network_access`、`exclude_tmpdir_env_var`、`exclude_slash_tmp` 四个旋钮。`SandboxMode`（命令行与 `sandbox_mode` 共用）只有三个取值，选不到 `ExternalSandbox`；后者表示“进程已经在别人搭好的沙箱里”，要经 app-server v2 协议这类途径传进来。运行时真正使用的是新表示 `PermissionProfile`：

```rust
pub enum PermissionProfile {
    /// Codex owns sandbox construction for this profile.
    #[serde(rename_all = "snake_case")]
    #[ts(rename_all = "snake_case")]
    Managed {
        file_system: ManagedFileSystemPermissions,
        network: NetworkSandboxPolicy,
    },
    /// Do not apply an outer sandbox.
    Disabled,
    /// Filesystem isolation is enforced by an external caller.
    #[serde(rename_all = "snake_case")]
    #[ts(rename_all = "snake_case")]
    External { network: NetworkSandboxPolicy },
}
```

（`codex-rs/protocol/src/models.rs:422`）

`Managed` 由 Codex 自己搭沙箱，文件系统部分要么是 `Unrestricted`，要么是 `Restricted { entries, glob_scan_max_depth }`；网络 `NetworkSandboxPolicy` 只有 `Restricted` 与 `Enabled` 两档。`Disabled` 不套沙箱，`External` 把文件隔离交给外部、自己只管网络位。旧新之间的映射写在 `SandboxEnforcement::from_legacy_sandbox_policy`（`codex-rs/protocol/src/models.rs:287`）：`DangerFullAccess` 对应 `Disabled`，`ExternalSandbox` 对应 `External`，其余对应 `Managed`。两边可以互相转换，旧表示留给还没迁移的代码路径；走旧语法时，推导出的配置档若无法表示成旧策略，配置加载会记一条日志、退回只读。

| 设置 | `PermissionProfile` | 文件系统条目 | 网络 |
| --- | --- | --- | --- |
| `read-only`、`:read-only` | `Managed` | `:root` 读 | `Restricted` |
| `workspace-write`、`:workspace` | `Managed` | `:root` 读，工作区根写，`/tmp` 与 `$TMPDIR` 写，`.git`、`.agents`、`.codex` 读 | 默认 `Restricted` |
| `danger-full-access`、`:danger-full-access` | `Disabled` | 不限 | `Enabled` |

## 文件系统条目：最具体者胜

每个条目 `FileSystemSandboxEntry` 由路径、访问级别和“路径不存在时怎么办”组成。路径 `FileSystemPath` 有三种：具体路径、glob 模式（目前只用于拒绝）、特殊路径（`:root`、`:minimal`、`:workspace_roots`、`:tmpdir`、`:slash_tmp`）。访问级别 `FileSystemAccessMode` 是 `read`、`write`、`deny` 三档（`none` 是旧别名），枚举顺序就是冲突优先级。内置 `:workspace` 在 `:root` 只读之后追加这些条目：

```rust
        entries.push(FileSystemSandboxEntry::new(
            FileSystemPath::Special {
                value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
            },
            FileSystemAccessMode::Write,
        ));
        // ...
        append_default_read_only_project_root_subpath_if_no_explicit_rule(&mut entries, ".git");
        append_default_read_only_project_root_subpath_if_no_explicit_rule(&mut entries, ".agents");
        append_default_read_only_project_root_subpath_if_no_explicit_rule(&mut entries, ".codex");
```

（`codex-rs/protocol/src/permissions.rs:823`）

判定一个路径能不能读写时，`Unrestricted` 与外部沙箱直接放行；否则找出所有覆盖它的条目，取路径最深的那条，深度相同再取优先级高的：

```rust
    pub fn resolve_access(
        &self,
        path: &PathUri,
        context: &FileSystemSandboxPolicyContext<'_>,
    ) -> FileSystemAccessMode {
        // ...
        entries
            .into_iter()
            .filter(|(root, _, _)| path.starts_with(root))
            .max_by_key(|(_, access, depth)| (*depth, *access))
            .map(|(_, access, _)| access)
            .unwrap_or(FileSystemAccessMode::Deny)
    }
```

（`codex-rs/protocol/src/permissions.rs:998`）

于是“`/repo` 可写、`/repo/secrets` 拒绝、`/repo/secrets/tmp` 又可写”这样的嵌套可以层层开合，同一路径上 deny 压过 write、write 压过 read。省略的中间部分还有几处兜底：路径约定（POSIX 与 Windows）对不上、路径无法按层级解析，一律返回 `Deny`，典型的失败即关闭（fail closed）。

<Callout type="info">
  **元数据保护**：`.git`、`.agents`、`.codex` 三个名字（`PROTECTED_METADATA_PATH_NAMES`，`codex-rs/protocol/src/permissions.rs:45`）在任何可写根下默认只读。除了上面追加的只读条目，写入判定还要过 `metadata_write_denial`：目标落在某个可写根下的这三个目录里，又没有更深的显式 write 条目，就拒绝；目录不存在也一样，首次创建同样得走审批。`.git` 是指向别处的 `gitdir:` 文件时（worktree、submodule），被指向的真实目录也一并保护。
</Callout>

## 配置档：从 TOML 编译成运行时

`[permissions.<name>]` 反序列化成 `PermissionProfileToml`，只有 `description`、`extends`、`workspace_roots`、`filesystem`、`network` 五个键（`codex-rs/config/src/permissions_toml.rs:113`）。编译规则集中在 `codex-rs/core/src/config/permissions.rs`：

- 自定义配置档不能以 `:` 开头，这个前缀留给内置档。`extends` 只能继承 `:read-only` 与 `:workspace`，继承 `:danger-full-access` 会报 “cannot extend unsupported built-in profile”；继承链先父后子合并 TOML，子覆盖父，成环时报 “inheritance cycle detected”。
- `filesystem` 的键是路径或特殊路径，值是访问级别，或者一张子表，给特殊路径下的相对子路径定规则，比如 `":workspace_roots"` 下写 `"**/*.env" = "deny"`。glob 只能配 `deny`，`read`、`write` 只允许末尾的 `/**`（整棵子树）；非 macOS 平台上，带 `**` 的拒绝 glob 没设 `glob_scan_max_depth` 会得到启动警告，因为 Linux 要在起沙箱前把 glob 展开成具体路径（见 [Linux 沙箱](https://daiw.net/manual/codex-source/linux-sandbox)）。
- 不认识的特殊路径只警告、不报错。`parse_special_path` 上方的注释写明了原因：0.112.0 曾因拒绝未知值，导致新版本写的配置在旧版本上加载失败。
- `network.enabled` 决定 `NetworkSandboxPolicy`；表里其余的代理设置只是备料，`network_proxy_config_from_profile_network` 会把代理的 `enabled` 强制置为 false，配置档本身不会拉起代理（见[网络代理](https://daiw.net/manual/codex-source/network-proxy)）。

`compile_permission_profile`（`codex-rs/core/src/config/permissions.rs:376`）的产物是 `CompiledPermissionProfile { permission_profile, workspace_roots }`。`:workspace_roots` 这样的符号先保留在条目里，等当前工作区根（cwd 加 `--add-dir` 追加的目录）确定后再物化成具体路径，同一份配置档因此能跟着工作目录走。

## 两套语法选哪套，默认是什么

```mermaid
flowchart LR
  CLI[命令行 --sandbox] --> SYN{选语法}
  LAY[各配置层<br/>sandbox_mode 或 default_permissions] --> SYN
  SYN -->|旧语法| DER[derive_permission_profile]
  SYN -->|配置档或都没写| CMP[compile_permission_profile]
  DER & CMP --> PP[PermissionProfile]
  REQ[requirements.toml<br/>允许列表与 deny_read] --> PP
  PP --> ORC[ToolOrchestrator<br/>审批与选沙箱]
  PP --> SBX[SandboxManager<br/>各平台沙箱]
  PP --> PRM[权限说明片段<br/>告诉模型边界]
```

`resolve_permission_config_syntax`（`codex-rs/core/src/config/mod.rs:2565`）的顺序是：命令行传了 `--sandbox` 就用旧语法；会话级覆盖里设了 `default_permissions` 就用配置档；否则从低到高扫一遍配置层，最后一个提到这两个键的层说了算。所以用户层写了 `sandbox_mode`、优先级更高的项目层写了 `default_permissions`，结果用配置档；同一层两个都写，也是配置档胜出。

两个都没写时走隐式内置档：`default_builtin_permission_profile_name`（`codex-rs/core/src/config/permissions.rs:51`）在项目有信任决定（`trusted` 或 `untrusted`）时选 `:workspace`，否则选 `:read-only`，Windows 上未启用沙箱时一律 `:read-only`。隐式 `:workspace` 仍会叠加旧的 `[sandbox_workspace_write]` 旋钮，显式写 `default_permissions = ":workspace"` 则忽略它们。另外，定义了 `[permissions]` 表、又没走旧语法，却没选 `default_permissions`，会直接报错，不会悄悄落到某个默认档上。

## 审批策略 AskForApproval

```rust
pub enum AskForApproval {
    /// Internal policy for projects marked untrusted. Commands require
    /// approval unless an explicit exec policy rule allows them.
    #[serde(rename = "untrusted")]
    #[strum(serialize = "untrusted")]
    UnlessTrusted,

    /// The model decides when to ask the user for approval.
    #[serde(alias = "on-failure")]
    #[default]
    OnRequest,
    // ...
    Granular(GranularApprovalConfig),
    // ...
    Never,
}
```

（`codex-rs/protocol/src/protocol.rs:986`）

- `on-request` 是默认值，旧值 `on-failure` 作为别名保留。注释说得直白：由模型决定何时请求。模型调用 `exec_command` 时带上 `sandbox_permissions = "require_escalated"` 和一句 `justification`，才会弹出审批，细节见[下一篇](https://daiw.net/manual/codex-source/approvals-flow)。
- `untrusted` 已经降为内部策略：配置里显式写它会报 “approval_policy = "untrusted" is no longer supported”。没有显式设置时，受信任项目用 `OnRequest`，标为 `untrusted` 的项目用 `UnlessTrusted`（规则没放行的命令都要批准），其余也是 `OnRequest`。
- `Granular` 有五个开关：`sandbox_approval`、`rules`、`mcp_elicitations` 必填，`skill_approval`、`request_permissions` 缺省为 false；为 false 的那类请求不弹窗，直接拒绝。`never` 则从不询问，失败直接交还模型。

审批交给谁由另一个值 `ApprovalsReviewer` 决定：`user`（默认）或 `auto_review`（旧名 `guardian_subagent` 仍可解析），后者见[自动审查](https://daiw.net/manual/codex-source/guardian)。

## deny_read：管理员划的读禁区

托管配置 `requirements.toml` 可以用 `[permissions.filesystem]` 下的 `deny_read` 列一组路径或 glob。与多数 requirements 键不同，它在各层之间是累加的（`codex-rs/config/src/requirements_layers/permissions.rs`），最后由 `FilesystemConstraints::apply_to_policy` 转成 `deny` 条目追加进生效的配置档。配置加载还给受约束的配置档挂了一个校验器：之后用 `/permissions` 换配置档，新档必须保留全部托管拒绝项，也不能把拒绝范围内的具体路径设成可读。

读拒绝只存在于沙箱内部，由此有一个连锁效应：策略里只要有读拒绝，`unsandboxed_execution_allowed` 就返回 false，即使用户批准了提权，命令也仍在沙箱里跑，保住这些拒绝（见[审批流程](https://daiw.net/manual/codex-source/approvals-flow)）。

## 模型怎么知道自己的边界

边界不仅要执行，还要告诉模型，否则它会反复撞墙。`PermissionsInstructions::from_permission_profile`（`codex-rs/prompts/src/permissions_instructions.rs:160`）从配置档反推一个模式名（全盘可写算 `danger-full-access`，没有可写根算 `read-only`，否则 `workspace-write`），连同可写根、网络开关、读拒绝清单、按审批策略挑选的说明模板（`on-request` 与 `granular` 时还附上规则里已放行的命令前缀），渲染成一段 `<permissions instructions>` 开发者消息注入上下文。读拒绝那一节明说：这些是策略限制，不要为读它们申请提权。

## 和《从 LLM 到 Coding Agent》对照

那本书的[权限系统](https://daiw.net/manual/llm-to-agent/permissions)是三层：工具自分类、规则、一个从 plan 到 bypass 的权限模式总开关。Codex 把总开关拆成正交的两个量：沙箱决定“技术上能做什么”，由操作系统强制执行；审批决定“越界前问不问”。两者自由组合，`read-only` 加 `never` 适合 CI，`workspace-write` 加 `on-request` 是日常最常见的组合（项目有信任决定时，隐式默认就是 `:workspace`，否则是 `:read-only`）。Grok Build 的[沙箱 profile](https://daiw.net/manual/grok-build/sandbox-permissions) 同样是“可写路径加是否限网”的组合，也能 `extends` 内置档；OpenCode 的[权限规则](https://daiw.net/manual/opencode/permission)按“最后一条匹配”求值，Codex 的文件系统条目则按“最深者胜、同深度 deny 胜”裁决，与书写顺序无关。

---

上一篇：[Code mode · 让模型写 JavaScript 编排工具](https://daiw.net/manual/codex-source/code-mode) · 下一篇：[审批流程 · 什么时候停下来问你](https://daiw.net/manual/codex-source/approvals-flow)
