# Shell 插值：别让智能体输入变成命令

智能体不需要 shell 工具，也可能触发 shell 执行。只要给它一个路径、分支、镜像标签、主机名或资源名称，然后在后续某处把这个值拼进命令字符串，你就让不可信文本有机会改变实际运行的程序。

解决办法不是写一个更聪明的引号函数，而是不再把命令构造当成字符串格式化。一个操作应该指定固定动作，接受遵循严格规则的类型化字段，通过真正的参数数组调用程序，并把授权与解析分开。我看过足够多的事故复盘，可以直说：一个因为“只是给我们的智能体使用”而接受自由格式命令的工具，迟早也会接受其他人的指令。

智能体让这种问题更容易出现。它们会读取问题文本、仓库内容、工具输出、网页和聊天消息。任何一种输入都可能影响下一个参数。智能体是否有恶意并不重要。只要边界会触达操作系统或远程主机，就必须假设攻击者能够塑造输入。

## Shell 插值会把数据变成语法

当代码为命令解释器构造文本，而不是通过进程 API 提供独立参数时，就发生了 shell 插值。一旦 shell 收到这段文本，就会应用自己的语言规则。分隔符可以启动另一个命令，命令替换可以运行嵌套命令，重定向可以修改文件，展开操作还可能把一个看似单一的值变成多个单词。

假设智能体需要在发布前检查一个分支。下面的处理程序看起来没有问题：

```python
branch = request["branch"]
command = f"git show {branch}"
subprocess.run(command, shell=True, check=True)
```

如果 `branch` 是 `release; id`，shell 会看到两个命令。第一部分要求 Git 显示一个修订，第二部分运行 `id`。同样的错误也会出现在 JavaScript 的 `exec`、Ruby 传给 `system` 的字符串、Go 中的 `sh -c`，以及把智能体输出直接放进 shell 变量、却没有控制其后续用途的 CI 脚本里。

危险字符不一定来自智能体的提示词。它可能藏在拉取请求标题、错误消息、软件包清单或仓库扫描返回的文件名中。任务要求智能体检查或修复这些内容时，它可能会在后续工具调用中引用其中的文字。只检查模型是否服从指令，会错过文本变成可执行语法的地方。

POSIX Shell Command Language 描述的处理序列包括展开、适用情况下的字段拆分、路径名展开和去除引号。这个顺序很重要。开发者常说“我已经把它放在引号里了”，仿佛引号能创建一个通用的安全字符串。事实并非如此。引号只会在特定 shell 的特定展开阶段，限制特定的解析规则。

Shell 接收输入的方式也不止一种。某个值可能影响命令文本本身、环境变量赋值、重定向目标、命令替换、shell 启动文件，或者稍后由另一个解释器执行的代码。针对第一个 shell 的转义，不会自动保护下一个解析器。

请明确区分以下概念：

- **命令注入**：输入改变了命令语言解释的语法。
- **参数注入**：输入仍是一个参数，却改变了被调用程序解释它的方式，通常是把它解释成选项或表达式。
- **授权失败**：合法参数请求了调用方无权执行的操作。

使用参数数组可以阻止第一类问题，却解决不了第二类和第三类问题。一个逃出工作区的路径、一个选择了非预期对象的 Git 修订，以及一个访问内部服务的 URL，都可能是完全独立的单个参数。

## 带引号的命令字符串仍然要经过太多解析器

先为输入加引号，再把它拼进 shell 命令，是常见的修复方式，因为它看起来改动很小。但它很脆弱，因为命令往往会经过不止一种语法。

假设本地程序构造 SSH 调用，而远程系统运行 shell。开发者可能先为本地 shell 引用分支，再为 SSH 引用一次，最后为远程 shell 再引用一次。每一层对空格、引号、反斜杠、命令替换和编码的假设都不同。后续维护可能删掉一层引号，或把已经加引号的字符串放入新的上下文。代码看起来仍然很谨慎，但保护已经失效。

下面这种写法尤其危险：

```javascript
const command = `git checkout '${branch}'`;
execFile("ssh", [host, command], callback);
```

`execFile` 保护了本地对 `ssh` 的调用，这一点很好。但它不会让 `command` 在远程端变安全。SSH 通常会向远程端提供一个命令字符串，而远程账户通常会把这个字符串交给 shell。因此，分支会在下一跳进入 shell 语法。

单引号不是通用答案。输入中只要包含单引号，就可能结束原本想要的引用区域。为 POSIX shell 编写的引号函数不适用于 PowerShell。适用于 shell 单词的函数，在值进入重定向、算术表达式或语言解释器时也不适用。即使函数今天正确，维护成本仍然存在，例如有人把 `git show` 改成 `git log --format=...`，并重新排列模板。

不要把用于日志的参数列表序列化，与用于执行的参数列表重建混为一谈。像 `git show release-42` 这样的日志行对人很有用，却无法证明原始参数边界在哪里。它不是安全的可执行表示。应把参数作为结构化数组保存，只在展示时按照清晰的转义规则渲染。

环境变量也会带来陷阱。下面的写法比插值安全：

```sh
BRANCH="$branch" git show "$BRANCH"
```

但只有当 shell 脚本自己负责所有加引号的展开，并且之后绝不再次执行这个值时，它才保持安全。如果下游脚本使用 `eval`、构造另一个命令字符串，或把值传给带有执行功能的模板引擎，前面的工作就所剩无几了。更好的边界是不让智能体的值进入 shell。

## 固定可执行文件和参数数组可以移除 shell 语法

对于本地操作，应使用参数数组调用固定的可执行文件，并关闭 shell 执行。操作系统会把你提供的各个字符串分别交给子进程，不会把分号、美元符号、空格、括号或通配符重新解释成 shell 语言。

Python 的 `subprocess` 文档建议使用参数序列，并说明 `shell=False` 是默认值。应有意识地使用这个默认值，而不是碰巧依赖它：

```python
import subprocess

result = subprocess.run(
    ["git", "status", "--porcelain=v1"],
    cwd=repo_dir,
    text=True,
    capture_output=True,
    check=True,
)
print(result.stdout)
```

这个操作没有由智能体控制的可执行文件，也没有由智能体控制的命令模板。它只会在批准的目录中执行一个已知操作。输出是普通文本，每条状态记录占一行。你还可以进一步改进接口：解析输出，返回结构化记录，而不是把原始文本继续交给智能体做决定。

操作需要输入时，应保持可执行文件和操作固定，并将经过验证的值作为独立参数追加：

```python
subprocess.run(
    ["tool", "fetch-resource", resource_id],
    cwd=workspace,
    check=True,
    shell=False,
)
```

在 Node.js 中，优先使用带参数数组的 `spawn` 或 `execFile`。不要为了方便执行复合命令而打开 `shell` 选项。在 Go 中，使用 `exec.Command`，分别提供每个参数。在 Rust 中，使用 `Command`，反复调用 `arg`。API 虽然不同，但原则相同：任何输入都不能进入命令语言。

这里有必要反驳一个常见建议：“使用 shell 脚本作为安全包装器。”固定 shell 脚本可以作为兼容性边界，但它不会因为是脚本就自动更安全。只有当它通过固定的位置参数或标准输入接收值、为脚本中的每次展开加引号、不使用 `eval`，并且不构造第二个命令字符串时，它才可能安全。通常，用进程 API 编写的小程序更容易审计。

管道、条件流程、重定向和 shell 内置命令都有合理用途。无法移除这些功能时，可以把它们放入经过审核的静态脚本中。但不要让智能体组装管道。给智能体一个名为 `collect_build_logs` 的操作，然后让包装器使用固定文件位置和有界参数运行固定管道。

可执行文件本身也属于策略的一部分。绝不要从智能体输入、可写配置文件或攻击者控制的 `PATH` 搜索结果中选择可执行文件。需要更强保证时，应使用管理员控制的绝对路径。设置明确的工作目录和精简环境，不要从智能体进程继承任意环境变量。

## 验证决定操作可以表达什么

参数数组保护了解析边界，验证保护了操作边界。如果某个操作名为 `read_file`，服务必须决定这个操作允许读取哪些文件。进程 API 无法替你做出这个决定。

从严格的模式开始。部署名称可以只允许小写字母、数字和单个内部分隔符，并设置长度限制。分支选择器可以只支持 Git 规则中的某个子集。资源标识符可以是 UUID，或库存中的整数。服务器目标可以是服务映射到已保存主机的 ID，而不是任意主机名。

避免使用“拒绝分号和与号”这种通用黑名单。它们围绕某个解析器构建，却没有解决语义层面的滥用，还会不断增长，直到普通用户无法预测什么输入能用。允许列表语法能给调用方提供明确契约，也能让审查者面对一个有界的输入集合。

对于发布流程中的分支字段，有时定义一个刻意受限的语法，比接受 Git 允许的所有引用更合适：

```python
import re

BRANCH = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/]{0,127}")

def release_ref(value: str) -> str:
    if not isinstance(value, str):
        raise ValueError("branch must be text")
    if not BRANCH.fullmatch(value):
        raise ValueError("branch has unsupported characters")
    if "/./" in value or "//" in value or value.endswith("/"):
        raise ValueError("branch has an unsupported path form")
    return "refs/heads/" + value
```

这段代码没有声称实现 Git 完整的引用语法。它有意为发布操作定义更小的语法，这通常是正确的工程选择。如果用户需要空格或其他特殊形式，应因为工作流确实需要而加入，然后测试并记录。不要因为操作只部署发布分支，就继承通用版本控制系统的所有边界情况。

返回值带有固定的命名空间前缀，这一点很重要。任意修订表达式可能指向标签、提交祖先、reflog，或包含 Git 命令会特殊解释的语法。发布分支操作不应悄悄变成“显示智能体能够描述的任何 Git 对象”。需要完整 Git 兼容性时，可以使用库或 Git 自带的分支验证模式，但仍应把接受的结果映射到允许的命名空间。

资源名称也需要同样的约束。如果智能体请求云对象，应把允许的账户、区域、存储桶或项目上下文放在自由格式字段之外。让它只提供符合唯一支持语法的对象名称。不要接受完整 URL 后再把它称为资源名称。完整 URL 会选择协议、主机、端口、路径，有时还会选择凭据。这是伪装成字符串的网络授权决策。

长度限制也是验证的一部分。它们可以限制日志增长，避免意外触及命令行长度限制，也让拒绝服务更难实施。字符编码规则同样属于验证。除非操作确实需要，否则应拒绝文本字段中的控制字符。对于进程参数，应无条件拒绝 NUL，因为操作系统进程接口无法把它作为参数的一部分传递。

## 路径需要包含性检查，而不是字符过滤

路径即使对 shell 安全，也可能对文件系统危险。`../../secrets.env` 不含引号函数关心的 shell 元字符，却仍然可以把文件操作带出工作区。

安全的路径策略首先要为操作声明根目录。根据这个根目录解析请求的相对路径，然后要求解析后的目标仍在解析后的根目录内。操作只需要工作区文件时，不要接受绝对路径。不要只规范化字符串，就假定字符串的结果能说明文件系统最终会访问哪里。

概念上的检查如下：

```python
from pathlib import Path

workspace = Path("/srv/agent-workspaces/repo-a").resolve()

def permitted_file(relative_name: str) -> Path:
    if not isinstance(relative_name, str) or relative_name.startswith("/"):
        raise ValueError("file must be a relative path")
    candidate = (workspace / relative_name).resolve(strict=True)
    if candidate == workspace or workspace not in candidate.parents:
        raise ValueError("file is outside the workspace")
    if not candidate.is_file():
        raise ValueError("requested target is not a regular file")
    return candidate
```

`resolve` 会捕获普通的路径穿越并跟随现有符号链接，比检查原始字符串是否包含 `..` 更好。但对于敏感写入操作，这仍不是完整答案。如果攻击者能够修改目录树，就可能在检查之后、后续打开之前替换路径组件，让它指向符号链接。这就是检查与使用之间的竞态，也称为检查时与使用时竞态。

对于受控工作区中的读取操作，解析后的包含性检查可能符合你的风险水平。对于写入、删除、修改权限，或攻击者能够影响的归档操作，应使用能够持有目录描述符、并在操作系统支持时拒绝沿符号链接遍历的文件系统操作。把可写的智能体工作区与决定允许根目录的代码分开。如果威胁模型包括能够竞争修改文件系统的本地攻击者，仅靠高级路径库是不够的。

还要决定点文件、仓库元数据、生成的依赖目录和符号链接是否属于范围。“仓库下的任何内容”这种模糊规则，往往会暴露凭据文件、构建缓存或工作流根本不需要的配置。读取权限应服从操作目的，而不是递归 glob 的便利。

归档解压需要特别警惕。把每个归档成员拼接到目标根目录后，都要进行验证，并把归档内的链接视为潜在恶意内容。路径穿越不只发生在智能体直接发出的请求中。代表智能体解压其选择的制品的工具，也可能替它完成同样的越界操作。

## 没有 shell，选项和表达式仍然危险

参数数组能让值与 shell 语法分开，但被调用程序仍会解析这个值。程序通常会把以连字符开头的参数当作选项。有些程序还接受表达式、配置引用、文件包含语法、插件或命令钩子。把不可信输入作为单个参数传递，不会让这些含义变得无害。

假设一个包装器用用户提供的模式调用搜索程序。包装器可能正确地提供 `["search", pattern, directory]`。如果程序把模式当作正则表达式，恶意复杂的表达式可能消耗大量 CPU。如果它支持读取配置文件的选项，而模式又落在错误位置，调用方可能改变程序行为。如果程序语言支持代码求值，你就把智能体交给了另一个解释器。

把固定选项放在数据之前，并在程序支持时使用传统的选项结束标记。在文字中，这个标记由两个连字符组成。它告诉许多命令行解析器，后续值是操作数而不是选项。但不要把它当成验证。有些工具不遵守这个约定，而且操作数本身仍可能具有危险的语义。

Git 的命令面很大，因此智能体需要的是范围狭窄的 Git 操作，而不是通用的“运行 Git”逃生口。`show_release_branch` 操作可以接受受限分支名，把它转换为 `refs/heads/` 加上该名称，然后调用固定的 Git 子命令。接受任意修订语法的 `checkout_anything` 含义更宽，也需要更广泛的授权决策。两者是不同的产品，即使都恰好启动 Git 进程。

同样的规则适用于软件包管理器、数据库客户端、媒体工具、归档程序和基础设施命令。要问的是：操作系统交付参数后，目标程序会解析哪种语法？命令行往往只是解析器链中的第一个。

正则表达式需要特别注意。模式不是 shell 代码，但它会在正则引擎中执行工作。如果智能体必须搜索文本，应尽可能选择性能可预测的正则引擎，限制模式和输入长度，并把字面搜索作为默认操作。不要因为开发者想要一个灵活端点，就把每个搜索请求都变成不受限的表达式语言。

## SSH 会把本地命令变成远程解析问题

SSH 创建了第二个执行边界。你可以用完美的参数数组调用本地 SSH 客户端，却仍然向远程主机发送不安全的命令字符串。SSH 的许多实现会通过远程账户的 shell 或等价的命令解析器执行请求的远程命令。

不要根据智能体字段构造远程命令。即使本地调用使用了数组，下面的写法仍然错误：

```python
remote = "deploy " + environment + " " + branch
subprocess.run(["ssh", host, remote], check=True)
```

安全的方式是发送固定的远程命令，并通过结构化输入通道传递数据。例如，远程账户可以在固定路径提供一个经过审核的程序。本地端启动这个固定程序，通过标准输入发送 JSON 请求，然后由远程程序验证 `environment` 和 `branch`，再运行任何本地操作。

```python
import json
import subprocess

request = {"environment": "staging", "branch": "release/42"}
subprocess.run(
    ["ssh", "deploy.internal", "/usr/local/libexec/receive-deploy-request"],
    input=json.dumps(request) + "\n",
    text=True,
    check=True,
)
```

这个例子中的远程命令是固定的。JSON 是标准输入中的数据，不是嵌入远程命令的文本。远程接收程序仍然必须拒绝未知字段、执行长度限制、验证每个值，并对任何子进程使用参数数组。结构化传输可以摆脱引号迷宫，但不会自动建立信任。

主机名通常也应由不透明的环境 ID 从已保存配置中选择。让智能体提供 `host`，意味着它可以决定凭据发往哪里，以及操作跨越哪条网络边界。如果工作流确实需要多个主机，应把 `staging` 和 `production` 等批准的名称映射到已知连接设置。保持主机验证开启。为了省事而关闭主机检查的工具，已经移除了边界中一个重要部分。

不要把凭据放进远程命令、命令行或 JSON 请求。使用远程账户已有的认证安排，并把账户权限限制在它必须执行的操作上。只能部署一个应用的部署接收器，比让智能体获得功能宽泛的 shell 会话更容易审查。

## 审批无法修复不安全的操作设计

人工审批和审计轨迹都是有用的控制手段，但它们回答的问题与验证不同。审批回答调用方现在是否可以尝试某个操作。验证回答请求是否具有明确且允许的形状。日志回答发生了什么，以及之后记录是否被改变。把其中任何一个当成其他手段的替代品，都会产生界面漂亮却防护薄弱的工具。

对于固定的操作网关，一次会话审批可能合适。但它并不是对会话剩余时间内每个插值字符串的仔细审查，也无法覆盖进程将字符串交给 shell 的情况。逐次调用审批能让人有更多机会发现异常目标，但不能指望任何人审核嵌套的引号规则，或在密集的对话框里发现恶意的 Unicode 伪装字符。

审批卡片应显示语义请求，例如“将分支 release/42 部署到 staging”，而不是从模板重建的 shell 命令。接收端应能将显示的环境和分支与请求模式进行比较。如果一个命令需要原始脚本字段才能解释自己，这个操作就过于宽泛，无法可靠审批。

在操作边界记录结构化事实。根据数据留存规则，保留调用方或智能体进程身份、已批准的操作名称、独立的参数或请求字段、选定目标、时间戳、退出状态和输出引用。验证失败也应记录。反复出现的路径穿越字符串或类似选项的值，可能暴露错误提示词、集成问题或主动探测。

对于使用 Sallyport 的团队，逐会话授权、逐调用密钥审批，以及独立的 Sessions 和 Activity 日志，可以围绕智能体操作保留控制和证据，但命令模板仍需遵循上述设计规则。

只有记录了重要边界，防篡改日志在事件发生后才有帮助。像 `ssh deploy internal deploy staging release 42` 这样的字符串，事后存在歧义。分别记录主机身份、固定接收器路径、JSON 字段、授权结果和接收器退出状态的事件，才能支持调查。

## 以同样的力度测试拒绝路径和成功路径

大多数命令注入测试只证明普通分支 `release/42` 能正常工作，这恰恰是最不值得关注的输入。测试套件应该证明：处理程序会在启动子进程或打开路径之前拒绝危险值。

为每种字段类型建立小型测试表。具体输入取决于语法，但类别应包括 shell 分隔符、命令替换、引号字符、空格和换行、类似选项的前缀、路径穿越组件、空值、控制字符、超长值，以及语法有效但请求了超出操作范围内容的值。对于分支操作，还应测试类似标签的名称，以及接口不支持的修订表达式。

在单元测试中使用假的进程运行器。断言可执行文件和每个参数都作为独立值传递。断言无效输入绝不会调用运行器。只比较渲染后的命令字符串，会错过你真正想保护的边界。

```python
class RecordingRunner:
    def __init__(self):
        self.calls = []

    def run(self, argv, **kwargs):
        self.calls.append((argv, kwargs))

def test_rejects_branch_separator():
    runner = RecordingRunner()
    try:
        release_ref("release/42; id")
    except ValueError:
        pass
    else:
        raise AssertionError("expected rejection")
    assert runner.calls == []
```

这个测试检查的是策略函数，而不是 shell。还应为进程边界添加集成测试。运行一个无害的固定程序，用明确的索引和长度打印接收到的参数。向它提供包含空格、引号、通配符、括号和换行的输入，确认每个值都作为一个参数到达，并且没有启动第二个程序。

定义好操作模式后，模糊测试会很有用。在允许和不允许的集合中生成随机 Unicode，然后断言结果只能是两种之一：验证器拒绝它，或固定操作把它作为一个有界字段接收。不要针对生产部署目标进行模糊测试。目的是在智能体接触真实凭据环境之前，发现网关和接收器中的解析假设。

最后，审查所有把结构化输入重新变成文本的地方，包括带有可点击命令的日志查看器、生成的 shell 片段、CI 配置、错误报告、聊天通知和远程包装器。安全的结构化请求常常会在后续组件中变成方便使用的字符串，注入漏洞也会在那里重新出现。

通常，最值得先修复的是最灵活的操作。把 `run_command(command)` 替换成一个有名称、小型请求模式、固定可执行文件，并且测试套件充满正常演示中没人会输入的值的操作。这样可以从威胁模型中移除整类模型行为，同时留下真正能够检查的规则。
