# 读懂一条命令 · 解析、安全判断与 shell 快照

> 模型交来的只是一段 shell 字符串，Codex 要从三个角度读它：给人看的语义摘要（读文件、搜索、列目录）、给审批用的安全判断、给执行提速的 shell 快照。解析靠 tree-sitter 分严格与宽松两档，前者用来逐条匹配规则，后者只用来找危险命令；0.158.0 的内置危险判断只盯带强制选项的 rm 与几类 Windows 命令，其余交给审批策略和沙箱。

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

# 读懂一条命令 · 解析、安全判断与 shell 快照

[上一篇](https://daiw.net/manual/codex-source/unified-exec)里，`exec_command` 把 `cmd` 原样交给 shell，比如 `bash -lc`。但在交出去之前和之后，Codex 还要把这段字符串拆开读好几遍。这些逻辑大多在 `codex-shell-command` 这个 crate 里，它依赖 `tree-sitter`、`tree-sitter-bash`、`tree-sitter-powershell` 与 `shlex`，只管“读懂”，执行命令不归它管。

## 用户看到的样子

在终端界面里，只由读文件、列目录、搜索组成的命令会被收进一个 “Exploring / Explored” 分组，每行一个动作：`Read foo.rs`、`List src`、`Search TODO in src`；其它命令照常显示命令本身和输出。审批方面，没有规则放行时，`rm -rf` 这类命令即使在沙箱里也会停下来问你，审批策略设为 `never` 时则直接拒绝（见[沙箱、审批与安全](https://daiw.net/manual/codex/sandbox-approvals)与[规则](https://daiw.net/manual/codex/rules)）。速度方面，`[features]` 里的 `shell_snapshot` 默认开启，它让每条命令不必重新执行一遍 `~/.bashrc` 或 `~/.zshrc`（见[配置参考](https://daiw.net/manual/codex/config-reference)）。

```mermaid
flowchart LR
  CMD[bash -lc 命令字符串] --> PC[parse_command<br/>语义摘要]
  PC --> EV[ParsedCommand<br/>事件 · 界面 · 遥测]
  CMD --> EP[commands_for_exec_policy<br/>严格解析成多条命令]
  EP --> RULE{有规则命中吗}
  RULE -->|有| DEC[按规则决定]
  RULE -->|没有| DG[dangerous_command_match<br/>宽松解析找危险字面量]
  DG --> DEC2[结合审批策略与沙箱决定]
  CMD --> SN[maybe_wrap_shell_lc_with_snapshot<br/>套上 shell 快照]
  SN --> RUN[启动进程]
```

## 两档解析：严格与宽松

`bash.rs` 用 tree-sitter-bash 解析 `-c` / `-lc` 后面的脚本，分两档。严格档 `try_parse_word_only_commands_sequence` 只接受由 `&&`、`||`、`;`、`|` 连起来的“纯单词命令”：语法树里一旦出现括号、重定向、命令替换、控制流，或者变量展开这类非字面量的词，就整体放弃、返回 `None`。宽松档则反过来，接受任何合法语法，把每个命令节点里能静态确定的词都抠出来，注释把它的用途限定得很清楚：

```rust
/// Extracts the literal portions of command invocations from a shell script.
///
/// Unlike [`parse_shell_lc_plain_commands`], this accepts complex shell syntax
/// and returns the statically known words from every command node in a valid
/// syntax tree. Dynamic words and redirections are omitted. This is suitable
/// for identifying dangerous literal commands, but must not be used to prove
/// that a command is safe.
pub(crate) fn parse_shell_lc_literal_commands(command: &[String]) -> Option<Vec<Vec<String>>> {
```

（`codex-rs/shell-command/src/bash.rs:129`）

这是一个不对称的设计：想证明“安全”，只能用看得懂每个字的严格档；想发现“危险”，宽松档看漏几个动态词也不至于放大风险，因为它的结论只会让命令多一次审批，从不让命令少一次审批。

## 语义摘要：读、搜、列

`parse_command.rs` 把命令归成 `ParsedCommand` 的四种之一：`Read`（带文件路径）、`ListFiles`、`Search`（带查询词与路径）、`Unknown`。`summarize_main_tokens` 认得 `cat`、`bat`、`head`、`tail`、`less`、`more`、`nl`、`awk`、`sed -n` 这类读文件的命令，`ls`、`eza`、`tree`、`du`、`git ls-files` 这类列目录的，`rg`、`grep`、`ag`、`git grep` 这类搜索的（`fd`、`find` 与 `rg --files` 视有无查询词归入搜索或列目录），PowerShell 的 `Get-Content` 也算读。脚本先走严格解析拆成多条，解析不了（比如用了重定向）就整段记为 `Unknown`；管道里的 `wc`、`sort`、`head -n 40` 这类“小格式化命令”被丢掉，好让摘要落在主命令上；`cd` 不单独成条，但会被记下来，拼出后续读文件的完整路径。只要有一段被判成 `Unknown`，整条命令就退化成一个 `Unknown`，宁可不摘要，也不给出半对半错的摘要。

这段代码的文档注释很少见：开头第一行是 “DO NOT REVIEW THIS CODE BY HAND”，接着说最省事的迭代方式是补单元测试、再让 Codex 去修实现（`codex-rs/shell-command/src/parse_command.rs:44`）。摘要的去处有三个：`ToolEmitter` 把它放进命令开始事件，终端据此画出 Exploring 分组；app-server 协议把它转成 `CommandAction` 交给其它客户端；[注册表](https://daiw.net/manual/codex-source/tool-architecture)分派时用它给遥测打 `command_category` 标签。

## 危险命令：只盯少数几种

`is_dangerous_command.rs` 的判断出奇地克制。POSIX 下只有一条规则，而且会穿透几层“包装”：

```rust
    match cmd0.as_deref() {
        Some("rm") if rm_args_include_force_option(&command[1..]) => {
            Some(DangerousCommandMatch::ForcedRm)
        }

        // For sudo <cmd>, simply check <cmd>.
        Some("sudo") => {
            dangerous_command_match_with_depth(&command[1..], wrapper_depth + 1, platform)
        }

        // Skip environment assignments before checking the command run by env.
        Some("env") => dangerous_command_match_for_env(command, wrapper_depth, platform),

        // A trap action is shell source stored in the first operand.
        Some("trap") => dangerous_command_match_for_trap(command, wrapper_depth, platform),

        // ...
        _ => None,
    }
```

（`codex-rs/shell-command/src/command_safety/is_dangerous_command.rs:132`）

`rm` 只要带 `-f`、`--force` 或 `-rf` 这样含 `f` 的组合短选项就算数；`bash -lc` 里的脚本用宽松档展开，所以藏在管道、`if`、`for`、`$(...)` 里的 `rm -rf` 也跑不掉。包装嵌套超过 8 层（`MAX_DANGEROUS_COMMAND_WRAPPER_DEPTH`）直接当作危险，失败时关门而不是放行。Windows 另有一套规则：`Remove-Item -Force`、`del /f`、`rd /s /q` 这类强制删除，以及用 `Start-Process`、ShellExecute、浏览器程序打开 URL 的命令。

判断结果只在执行策略里用。`codex-rs/core/src/exec_policy.rs` 先把 `bash -lc` 用严格档拆成多条命令，逐条匹配 Starlark 规则（规则本身见[执行策略](https://daiw.net/manual/codex-source/execpolicy)）；拆不开就按整条 argv 匹配。没有规则命中的命令，才轮到这段兜底逻辑：

```rust
    // If the command is flagged as dangerous or we have no sandbox protection,
    // we should never allow it to run without approval.
    //
    // We prefer to prompt the user rather than outright forbid the command,
    // but if the user has explicitly disabled prompts, we must
    // forbid the command.
    if dangerous_command_match.is_some() || windows_managed_fs_restrictions_without_sandbox_backend
    {
        return match approval_policy {
            AskForApproval::Never => Decision::Forbidden,
            AskForApproval::OnRequest
            | AskForApproval::UnlessTrusted
            | AskForApproval::Granular(_) => Decision::Prompt,
        };
    }
```

（`codex-rs/core/src/exec_policy.rs:793`）

被拒绝时模型看到的理由是 `rm -f style commands are not permitted. Use a safer approach`。不危险的未命中命令则看审批策略与沙箱：`never` 直接放行、靠沙箱兜底；`untrusted` 一律询问；`on-request` 同样放行，只有在受限沙箱里主动申请越过沙箱时才询问。换句话说，0.158.0 的内置逻辑里没有“哪些命令天生安全”的白名单，安全感主要来自沙箱，想免审批的命令要靠规则明确放行。

## 用哪个 shell

`shell_detect.rs` 决定默认 shell：Windows 上是 PowerShell，找不到就退回 `cmd.exe`；Unix 上读 passwd 里登记的登录 shell（用线程安全的 `getpwuid_r`），不认识或找不到时，macOS 依次试 zsh、bash，其它系统依次试 bash、zsh，最后兜底 `/bin/sh`。模型通过 `shell` 参数指定的路径只用来判断类型，可执行文件仍由 Codex 自己去找，函数名 `get_shell_by_model_provided_path` 的注释写得明白。

## shell 快照：把 rc 文件的效果缓存下来

命令默认以登录 shell 启动，每次都要执行一遍用户的 rc 文件，慢且可能有副作用。shell 快照（`codex-rs/core/src/shell_snapshot.rs`）的做法是：环境建好时在后台启动一次捕获，用登录 shell 执行一段脚本，先加载 `~/.bashrc` 或 `~/.zshrc`，再把 `shopt -p`、`declare -f`（函数）、`set -o` 选项、别名与导出的变量写成一个可 source 的文件，放在 `CODEX_HOME/shell_snapshots/` 下，文件名是线程 ID 加一个时间戳。捕获限时 10 秒，写完还要用 `set -e` 再 source 一遍验证，通过了才改名生效；PowerShell 与 cmd 不支持。快照是异步的，命令执行时只“看一眼”它好了没有，没好就照常跑，不会为它等待。

有了快照，`maybe_wrap_shell_lc_with_snapshot` 把 `bash -lc '<命令>'` 改写成一个非登录的 `bash -c` 脚本：

```rust
    let rewritten_script = if override_exports.is_empty() {
        format!("if . '{snapshot_path}' >/dev/null 2>&1; then :; fi\n\n{run_original}")
    } else {
        format!(
            "{override_captures}\n\nif . '{snapshot_path}' >/dev/null 2>&1; then :; fi\n\n{override_exports}\n\n{run_original}"
        )
    };
```

（`codex-rs/core/src/tools/runtimes/mod.rs:506`）

先静默 source 快照，失败也不影响后续；再把 `CODEX_THREAD_ID` 等运行时变量与用户在 `shell_environment_policy.set` 里明确要求的值重新导出，免得被快照里的旧值盖掉；最后 `exec` 原来的 shell 以 `-c` 跑命令。Windows 上不做这层包装。快照文件在对应对象释放时删除，没删干净的，下次创建快照时会顺手清理：找不到所属线程、或者线程的会话文件超过 3 天没动过，都会被删。启用了网络代理的凭据代管时，快照改为在命令自己的沙箱里按需捕获（`codex-rs/core/src/shell_snapshot_sandbox.rs`）；还在开发中的 `shell_snapshot_v2` 则把捕获挪到 exec-server 一侧。

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

[权限系统](https://daiw.net/manual/llm-to-agent/permissions)讲的第一层是“工具自分类”，靠工具自报读写属性决定要不要问人。Codex 对 shell 命令没有走“自分类”，因为同一个工具里可以塞进任何命令：它把分类拆成两件事，语义摘要只服务展示，安全判断则落在执行策略与沙箱上，而且只在“证明安全”时要求完全看懂命令，“发现危险”时宁可多报。那一篇强调的 fail-closed 在这里也看得到：看不懂的命令，摘要退化成 `Unknown`、规则按整条命令匹配，包装嵌套太深就直接当作危险。

---

上一篇：[执行命令 · shell 与 unified exec](https://daiw.net/manual/codex-source/unified-exec) · 下一篇：[apply_patch · Codex 的文件编辑格式](https://daiw.net/manual/codex-source/apply-patch)
