# 强制 SSH 命令可以包含 AI 服务账户吗？

AI 代理绝不能拿到一份意味着“账户能做什么就都能做”的 SSH 凭据。这不是权限边界，而是在邀请它寻找边界，通常是通过你没预料到的参数、忘记禁用的转发连接，或过度信任调用方的部署脚本来实现。

强制 SSH 命令让远程服务器最终决定认证后启动什么程序。对于部署和诊断账户，它们很有用，因为它们把模糊的能力，也就是远程 shell 访问，替换成一个由你拥有、可以检查的明确操作。但它们不能替代人员对凭据使用的审批。把这两个控制点分开：人员决定代理是否可以使用凭据，服务器决定该凭据能执行哪项受限操作。

## 强制命令限制的是执行，不是认证

强制命令会告诉 sshd，即使客户端请求 shell 或提供了其他命令，也要运行服务器选定的程序。客户端仍然要先完成认证。这个区别听起来很明显，但当服务账户出现在代理配置中时，人们很容易把成功登录当成已经获批的部署。

OpenSSH 在两个位置支持这项控制。你可以在 authorized_keys 中为某个公钥附加 `command="/path/to/wrapper"`，也可以在 sshd_config 中为某个用户或用户组设置 `ForceCommand`。两种情况下，sshd 都会把客户端请求的命令记录在 `SSH_ORIGINAL_COMMAND` 环境变量中，然后启动强制程序。

OpenSSH 的 `sshd(8)` 手册对第一点说得很直接：`command` 选项会在认证后强制执行指定命令。手册也说明，原始命令仍然会提供给这个强制程序。许多薄弱设计正是在这里失败的。包装脚本收到的是不可信客户端传来的字符串，必须把它解析成请求，而不是把它交给 shell。

当一个账户有几份严格分开的凭据时，可以使用按密钥设置的形式。发布凭据可以启动部署包装脚本，运维凭据则可以启动只读诊断包装脚本。这样，意图会清楚地显示在 authorized_keys 中，你也能撤销其中一份凭据，而不必修改账户的其他访问权限。

当账户无论通过什么方式认证都绝不能提供通用 shell 时，可以使用 `ForceCommand`。这包括你忘记禁用的密码、未来添加的证书颁发机构，或者管理员新增公钥时没有复制必要选项的情况。使用 `Match User deploy` 代码块，也能让审查时不容易漏掉这条规则。

不要把这两种形式用来把人工管理员账户改造成自动化账户。人员迟早需要真正的 shell 来处理修复工作。请为自动化单独创建 Unix 账户、单独的凭据和单独的命令包装脚本，并根据任务划分合适的所有权边界。

## 服务器必须拥有部署入口

部署账户应该进入一个由你控制的脚本，而不是通用命令解释器。脚本可以接受少量请求格式，但仓库路径、目标目录、服务单元和可执行程序都应由脚本自行决定。

下面这个 authorized_keys 条目把一份凭据限制到一个包装脚本，并拒绝部署账户不需要的连接功能：

```text
restrict,command="/usr/local/libexec/release-gate" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... release-agent
```

`restrict` 选项很有用，因为 OpenSSH 文档说明它是一个简写形式，会禁用端口转发、代理转发、X11 转发和伪终端分配。它的具体行为取决于服务器所支持的 OpenSSH 选项，因此要在实际运行的版本上测试。如果你的环境需要为了审查或兼容性而明确写出各项选项，可以这样写：

```text
command="/usr/local/libexec/release-gate",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... release-agent
```

包装脚本不应接受自由格式的部署命令。给调用方固定动词和受限值。例如，调用方只能通过不可变版本请求发布：

```text
ssh release@deploy.example "release 9f2a7c6d1e4b8a03"
```

安全的包装脚本可以只允许这种格式：

```sh
#!/bin/sh
set -eu

request=${SSH_ORIGINAL_COMMAND-}
case "$request" in
  "release "[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]* )
    revision=${request#release }
    case "$revision" in
      *" "*|*[!0-9a-f]*)
        echo "invalid revision" >&2
        exit 64
        ;;
    esac
    exec /usr/local/libexec/run-release "$revision"
    ;;
  *)
    echo "unsupported remote request" >&2
    exit 64
    ;;
esac
```

如果版本格式要求固定长度，这个示例仍然需要加入长度检查。生产环境中的包装脚本应接受完整的不可变对象 ID，或接受由你定义格式的发布标识符。如果调用方可能在审批和部署之间移动分支，不要接受 `main` 这样的分支名。分支只是一个指针。不可变版本能让审批记录、部署日志和最终产物指向同一个对象。

`run-release` 脚本应使用绝对路径，并自行设置环境。不要依赖调用方提供的 `PATH`、工作目录、区域设置、`GIT_DIR`、`GIT_SSH_COMMAND` 或 `LD_PRELOAD`。一个最小的开头可以是：

```sh
#!/bin/sh
set -eu
PATH=/usr/sbin:/usr/bin:/sbin:/bin
export PATH
unset CDPATH ENV BASH_ENV GIT_DIR GIT_WORK_TREE GIT_SSH_COMMAND
cd /srv/release-repo

revision=$1
/usr/bin/git cat-file -e "$revision^{commit}"
/usr/local/libexec/build-and-activate "$revision"
```

账户只应拥有它必须修改的文件。如果账户需要重启服务，请授予一条参数固定的精确 `sudoers` 命令，而不是允许无密码使用通用包管理器或 shell。能够修改自己包装脚本、自己的 authorized_keys，或运行自身代码的服务单元的部署账户，通常都能重新获得广泛控制权。检查这些路径，不要只检查 SSH 配置。

## `SSH_ORIGINAL_COMMAND` 是输入，不是命令行

强制命令最常见的错误是这一行：

```sh
sh -c "$SSH_ORIGINAL_COMMAND"
```

这一行会取消你刚刚建立的控制。客户端可以请求 `release goodrev; curl ... | sh`、命令替换、重定向输出，或通过精心引用的参数把内容传给特权工具。调用 `eval`、`sh -c`、`bash -c`，或进行未加引号展开的包装脚本，只是换了一个文件名重新提供远程 shell 访问。

不要尝试构建完整的 shell 解析器，你不需要它。定义一个刻意保持很小的协议，拒绝所有超出协议的内容。对部署账户来说，请求可以是一个动词加一个标识符。对诊断账户来说，请求可以是一个精确单词，例如 `health` 或 `version`。

诊断分发包装脚本甚至可以完全避免解析：

```sh
#!/bin/sh
set -eu

case "${SSH_ORIGINAL_COMMAND-}" in
  health)
    exec /usr/local/libexec/report-health
    ;;
  queue-depth)
    exec /usr/local/libexec/report-queue-depth
    ;;
  version)
    exec /usr/local/libexec/report-version
    ;;
  "")
    echo "a diagnostic name is required" >&2
    exit 64
    ;;
  *)
    echo "diagnostic is not allowed" >&2
    exit 64
    ;;
esac
```

这些诊断脚本也必须自行控制参数。`report-health` 应针对固定的本地套接字或已知服务名调用固定二进制文件。它不应接受主机参数后运行 `curl "$host"`，也不应接受日志筛选条件后把它传给 shell。只读命令仍然可能泄露数据库凭据、内部网络拓扑、环境值或客户数据。

人们常说，只要 shell 命令引用方式正确就够了，因为调用代理是可信的。代理可能遵循恶意仓库中的指令，把值误认为指令，或者犯下普通错误，这个说法就站不住脚了。远程服务器无法判断危险请求来自恶意行为，还是来自过于积极的工具调用。它看到的只有输入。让它的判断保持确定性。

如果需要结构化输入，请使用有边界的格式，并用会拒绝额外字段的解析器来解析。JSON 并不会自动变得更安全，因为 shell 包装脚本仍然可能处理不当。像 `release <64 lowercase hex characters>` 这样的小型请求，比带有可选字段的 JSON 数据块更容易验证、记录、测试和审计。

## 转发可能绕过限制的初衷

强制命令不会自动阻止经过认证的客户端把 SSH 当作隧道使用。OpenSSH 手册将命令执行和转发视为两套独立控制。如果只添加 `command="..."`，客户端仍可能要求 sshd 将本地端口转发到内部服务，具体取决于服务器的其他配置。

这很重要，因为受限账户可能拥有代理本身没有的网络访问能力。即使代理不能在远程主机上运行 `/usr/bin/ps`，只要转发仍然开放，它仍可能通过该主机访问数据库端口。此时，这个账户不再是部署身份，而变成了网络跳板。

对于不需要交互式会话的账户，除非你能明确说明账户为什么需要，否则应拒绝以下所有能力：

- TCP 转发
- 代理转发
- X11 转发
- 伪终端分配
- 用户控制的环境变量

在现代 OpenSSH 部署中，`restrict` 会处理前四类能力。如果账户确实需要某项例外，不要因此取消整套限制。OpenSSH 支持 `permitopen="host:port"` 等选项来限制转发目标。把它当作独立的访问设计，并同时测试允许和拒绝的目标。

还要检查包装脚本的出站网络访问。即使 SSH 转发已禁用，能够获取任意 URL、克隆任意仓库或向外发送任意数据的部署脚本，仍然拥有广泛的通信渠道。固定制品源和固定版本可以降低暴露面，其余限制可能需要由防火墙规则或服务专用凭据来承担。

## 连接建立前完成审批

强制命令可以降低一次已获批 SSH 使用所造成的损害，但它不会回答当前代理进程是否应该使用这份凭据。这个决定应发生在凭据边界，也就是代理建立 SSH 连接之前。

对自主编程代理来说，这一点尤其重要。仓库可能指示代理运行部署命令，工具输出可能请求运行部署，受入侵的依赖也可能把代理引向部署。如果凭据位于代理的环境或文件系统中，代理就能在无人看到使用时刻的情况下直接使用它。

让 SSH 私钥留在代理进程之外，并在新的代理运行首次请求访问时要求审批。对于影响重大的账户，每次使用凭据都要求审批。这样，远程强制命令就会为这次审批授权的操作设置一个明确上限。

Sallyport 通过将 SSH 密钥保存在加密保管库中，默认按会话授权新的代理进程，并通过 `sp-ssh` 辅助程序执行 SSH，而不是把密钥交给代理，从而落实了这种分工。

不要把审批卡片和服务器授权混为一谈。审批回答的是：“这个进程现在可以使用这份凭据吗？”服务器回答的是：“这个凭据登录后可以做什么？”两个答案都需要，因为它们防范的是不同问题。审批可以阻止出人意料的进程，强制命令则可以阻止一个已获批的进程把发布凭据变成 shell。

让审批说明真正有用。在凭据标签中写明环境和操作，例如 `production release` 或 `staging diagnostics`。名为 `deploy-key-2` 的标签会要求审查者在中断中回忆历史。这正是例行审批最终变成自动点击的方式。

## 在允许列表不断扩大前分开部署和诊断

部署和诊断看起来相似，因为两者都需要 SSH，但它们的数据流和失败方式不同。只要条件允许，就把它们放在不同账户或不同的强制命令凭据后面。

部署账户会改变状态。它可能获取固定版本、构建制品、替换发布目录并重启一个服务。它的输出应报告版本、目标、退出状态和简短的失败消息。它不需要任意日志访问、进程检查或数据库查询。

诊断账户读取状态。它可以报告健康端点结果、受限的队列数量、服务版本，或经过严格筛选的本地日志尾部。它不应重启服务、轮换文件、查询所有进程或读取任意路径。一旦诊断包装脚本接受用户提供的文件名、单元名、主机或命令选项，就应重新审查它的输入模型。

一个合并账户通常从一份看似无害的列表开始：

```text
release <revision>
health
logs <service>
restart <service>
```

然后有人需要 `logs api --since`，另一个人需要紧急重启，包装脚本开始把参数传给 `journalctl` 或 `systemctl`。很快，代码里充满没人能解释的特殊情况。在这种情况发生前就拆分账户。分开的凭据让你可以对生产环境变更要求更严格的审批，同时保留风险较低的诊断流程。

每项操作都应生成一条记录，说明包装脚本接受了什么，而不只是记录不透明的 SSH 命令字符串。发布操作记录不可变版本和目标名称。诊断操作记录诊断名称以及是否成功。不要在命令参数或日志中放入秘密。如果操作需要秘密，远程脚本应通过自身受控的机制获取，而不是从 SSH 客户端接收。

## 使用临时客户端测试拒绝路径

受限账户只有在测试过它必须拒绝的请求后，才算真正受限。在信任生产环境配置前，请从临时账户或测试主机运行以下检查。示例假定凭据已经安装在服务器上。

```sh
ssh release@deploy.example "release 9f2a7c6d1e4b8a03"
ssh release@deploy.example
ssh release@deploy.example "id"
ssh release@deploy.example "release 9f2a; id"
ssh -N -L 15432:db.internal:5432 release@deploy.example
ssh -tt release@deploy.example "health"
```

第一条命令只有在版本满足规则时，才应到达发布包装脚本。接下来的三条命令应由包装脚本返回拒绝消息和非零退出状态。端口转发尝试应在建立监听器前失败。终端请求应失败，或者在没有终端的情况下运行，具体取决于客户端如何报告被拒绝的分配请求。

接着测试那些不明显的情况。尝试开头和结尾的空格、制表符、空的带引号命令、换行符、超长参数、Unicode 空白、命令替换、重定向和重复参数。如果 shell 或包装脚本会把其中任何情况规范化为可接受请求，就收紧语法。

测试时也要检查账户的文件权限和所有权。能够替换 `/usr/local/libexec/release-gate` 的攻击者不需要绕过 SSH。能够修改自己的部署源的账户，也可能修改以更高权限运行的代码。检查完整链路：`authorized_keys`、sshd 配置、包装脚本、部署脚本、服务定义、可写目录以及任何 `sudoers` 条目。

## 在边界两侧审计请求

远程日志说明服务器接受了什么，凭据边界日志说明哪个本地进程请求了连接能力。两者都要保留，因为任何一侧都无法回答另一侧的问题。

在远程侧记录认证成功、强制包装脚本接受的操作、不可变版本或诊断名称、请求标识符和最终状态。将这些记录发送到服务账户无法改写的位置。如果调用方可能把秘密放进 `SSH_ORIGINAL_COMMAND`，不要记录原始值，也不要让协议一开始就接受秘密。

在本地侧保留会话身份和每项 SSH 操作请求。Sallyport 会在同一份加密、哈希链式审计日志中，将代理运行和单独调用记录在不同日志中，`sp audit verify` 可以在没有保管库密钥的情况下离线验证这条链。

哈希链不会把糟糕的权限变成良好权限，但它能让后续篡改记录历史更容易被发现。这对失败的部署、有争议的审批或出人意料的命令请求都很有用。它也会带来一个良好习惯：尽早定义操作词汇，让记录读起来是人能理解的内容。

第一次实现应该朴素而无聊。创建一个专用 Unix 账户、一个强制包装脚本、一个固定输入语法的操作，禁用转发，并用测试证明 `ssh account@host` 不会提供 shell。只有在你能明确说明新增能力的输入、输出、文件访问、网络访问以及应由谁批准后，才继续扩展能力。
