阅读需 8 分钟

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

强制 SSH 命令将受 AI 控制的服务账户限制在指定的服务器操作内,同时把人工审批保留在凭据边界。

强制 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 条目把一份凭据限制到一个包装脚本,并拒绝部署账户不需要的连接功能:

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

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

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

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

ssh [email protected] "release 9f2a7c6d1e4b8a03"

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

#!/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_DIRGIT_SSH_COMMANDLD_PRELOAD。一个最小的开头可以是:

#!/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 -c "$SSH_ORIGINAL_COMMAND"

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

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

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

#!/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、克隆任意仓库或向外发送任意数据的部署脚本,仍然拥有广泛的通信渠道。固定制品源和固定版本可以降低暴露面,其余限制可能需要由防火墙规则或服务专用凭据来承担。

连接建立前完成审批

将受限账户与审批结合起来
强制命令限定服务器账户的能力,Sallyport 控制代理何时可以请求使用 SSH 密钥。

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

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

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

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

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

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

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

查看每次 SSH 调用
Activity 日志会在加密、哈希链式审计日志中记录每一次 SSH 调用。

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

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

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

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

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

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

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

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

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

ssh [email protected] "release 9f2a7c6d1e4b8a03"
ssh [email protected]
ssh [email protected] "id"
ssh [email protected] "release 9f2a; id"
ssh -N -L 15432:db.internal:5432 [email protected]
ssh -tt [email protected] "health"

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

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

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

在边界两侧审计请求

让 SSH 密钥离开代理
Sallyport 的加密保管库保存 SSH 密钥,由 sp-ssh 建立连接。

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

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

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

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

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

常见问题

什么是 SSH 强制命令?

可以在 authorized_keys 条目中加入 command="..." 选项来使用 SSH 账户,也可以在 sshd_config 中使用 ForceCommand,让该账户通过任何认证方式登录时都遵循同一规则。服务器会运行你的包装脚本,而不是客户端提供的命令,并通过 SSH_ORIGINAL_COMMAND 传递原始请求。

强制 SSH 命令足以保护 AI 服务账户吗?

不能。强制命令只限制 SSH 服务器在认证成功后启动什么程序,不会决定谁可以使用凭据。应将它与凭据边界结合起来,要求人员批准新的代理运行或敏感操作,同时把服务器账户限制在足够狭窄的范围内,让批准带来的后果可控。

应该使用 ForceCommand 还是 authorized_keys 中的 command?

通常优先使用 authorized_keys 中的专用密钥。这样一个凭据可以清晰对应一条强制命令规则,而共享账户设置可能同时影响人员和自动化程序。当你确实希望进入受限账户的所有路径都经过同一个包装脚本时,再使用 ForceCommand。

强制命令如何接收原始 SSH 命令?

OpenSSH 运行强制命令时,会把客户端请求的命令保存到 SSH_ORIGINAL_COMMAND 中。应将这个值视为不可信输入,拒绝 shell 元字符和未设计的选项,只匹配少量明确的命令格式。

authorized_keys 中的 command= 会禁用 SSH 端口转发吗?

不能。单独使用 command="..." 选项不会关闭端口转发、代理转发、X11 转发或伪终端。适合时加入 restrict,或者逐项明确禁用这些能力,并从客户端测试结果。

如何将 SSH 账户限制为只能执行部署?

部署账户应只运行一个由你维护的脚本,并使用固定路径、固定工作目录和受限环境,同时只允许部署目标列表中的目标。不要接受任意分支、主机、路径或 shell 片段,再把它们传给 git、rsync、sudo 或 shell。

强制命令包装脚本应该拒绝什么?

空请求、shell、未知子命令、格式错误的参数,以及包含意外空白或元字符的请求,都应返回非零状态。记录认证账户和来源信息,但绝不要记录通过环境变量或命令文本传入的秘密。

AI 编程代理可以非交互式地使用强制 SSH 命令吗?

强制命令适用于 SSH,因为认证后启动哪个程序由服务器决定,而不是由 AI 进程决定。它不要求远程主机提供交互式提示,因此在保留凭据边界审批的前提下,适合非交互式代理运行。

如何审计通过强制 SSH 账户执行的操作?

保留远程包装脚本日志、部署日志和 SSH 认证日志,然后将它们与凭据使用记录关联起来。具备防篡改能力的本地操作日志很有用,因为它记录了哪个代理进程在远程服务器收到连接之前请求使用凭据。

部署和诊断 SSH 访问应该使用独立账户吗?

当诊断任务与部署在用途、允许的命令或后果上不同时,应使用独立账户。把两者合并到一个包装脚本中,允许列表通常会不断膨胀,最后变成一个意外的远程 shell。

Sallyport

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

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