阅读需 8 分钟

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

Shell 插值会把智能体控制的路径和资源名称变成可执行语法。使用参数数组和验证,为本地及 SSH 操作建立更安全的边界。

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

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

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

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

Shell 插值会把数据变成语法

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

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

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

如果 branchrelease; 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 再引用一次。每一层对空格、引号、反斜杠、命令替换和编码的假设都不同。后续维护可能删掉一层引号,或把已经加引号的字符串放入新的上下文。代码看起来仍然很谨慎,但保护已经失效。

下面这种写法尤其危险:

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 这样的日志行对人很有用,却无法证明原始参数边界在哪里。它不是安全的可执行表示。应把参数作为结构化数组保存,只在展示时按照清晰的转义规则渲染。

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

BRANCH="$branch" git show "$BRANCH"

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

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

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

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

import subprocess

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

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

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

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

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

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

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

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

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

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

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

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

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

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,因为操作系统进程接口无法把它作为参数的一部分传递。

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

控制智能体的出站操作
将 Sallyport 放在 MCP 智能体与它需要使用的 HTTP API 或 SSH 命令之间。

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

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

概念上的检查如下:

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 会把本地命令变成远程解析问题

锁定操作边界
加密保险库锁定时,Sallyport 会拒绝所有智能体操作。

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

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

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

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

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,意味着它可以决定凭据发往哪里,以及操作跨越哪条网络边界。如果工作流确实需要多个主机,应把 stagingproduction 等批准的名称映射到已知连接设置。保持主机验证开启。为了省事而关闭主机检查的工具,已经移除了边界中一个重要部分。

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

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

不要把 API 密钥放进提示词
Sallyport 会把 bearer、basic 或自定义请求头凭据注入 HTTP 调用,而不是交给智能体。

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

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

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

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

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

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

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

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

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

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

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) 替换成一个有名称、小型请求模式、固定可执行文件,并且测试套件充满正常演示中没人会输入的值的操作。这样可以从威胁模型中移除整类模型行为,同时留下真正能够检查的规则。

常见问题

Shell 引号足以防止命令注入吗?

引号只能改变某个 shell 解析值的方式。它无法决定这个值是否代表允许访问的文件、分支、主机或 API 资源。应把引号视为解析层面的保护,把验证视为权限设计,两者都需要。

是否应该允许 AI 智能体使用 sh -c?

通常不应该。应使用接受可执行文件和参数数组的进程 API,并关闭 shell 执行。这样可以移除 shell 语法层,但你仍然必须根据目标程序的语义验证参数。

看起来安全的路径仍然会造成损害吗?

../../private/config 这样的路径不含 shell 元字符,却仍然可以逃出预期目录。应根据批准的根目录解析路径,在解析后检查路径是否仍在根目录内,并在打开或操作前考虑符号链接。

命令通过 SSH 运行时,参数数组还安全吗?

不会。单独的参数对本地进程来说仍是数据,但 SSH 通常会把命令字符串交给远程 shell 解析。应发送固定的远程命令,通过标准输入传递结构化数据,并在远程端再次验证。

如何验证智能体提供的 Git 分支?

拒绝不符合工作流实际支持的语法,然后把通过验证的名称转换为固定的引用命名空间,例如 refs/heads/ 加上该名称。不要因为 Git 能解析任意修订、引用表达式或命令选项,就全部接受它们。

为什么间接提示注入会影响 shell 命令?

提示注入不一定看起来有恶意。仓库文件、问题描述、构建日志或 API 响应都可能告诉智能体把危险值传给工具。即使智能体忠实执行指令,工具边界也必须自行强制执行规则。

审批对话框能防止不安全的智能体命令吗?

审批只能说明某个进程现在可以发起调用。它不会把不安全的命令模板变安全,也不能证明参数在允许范围内。应先设计安全的操作,再让人审批。

智能体命令的审计日志应记录什么?

有用的审计记录应包含可执行文件、单独记录的每个参数、调用方身份、目标上下文、结果,以及结构化输入的摘要或安全保存的副本。单一的重建命令行会丢失调查时需要的边界信息。

应该屏蔽特殊字符,还是使用允许列表?

只要产品能够接受受限语法,拒绝所有特殊字符才有意义。更好的做法是使用符合实际领域的允许列表,例如简单的部署名称或仓库分支,并记录支持的形式。不要假装通用黑名单能够理解涉及的每一种解析器。

如何测试智能体操作网关是否存在命令注入?

先从一个边界明确的操作开始:它只接受一个有限操作、一个结构化请求,而且没有 shell 命令字段。使用分隔符、命令替换、开头为选项符号的字符、路径穿越字符串、换行符,以及语法有效但超出范围的值进行测试。如果任何情况触发了非预期操作,接口就过于宽泛。

Sallyport

Sallyport 替你的 AI 智能体执行 API 调用和 SSH 命令。密钥留在你 Mac 上的本地密钥库里;每次运行由你批准,每个操作都落入一份密封的审计日志。

© 2026 Sallyport · 依据 Apache-2.0 开源 · Oleg Sotnikov