# SSH 启动文件：远程命令为何会欺骗代理

SSH 命令并不一定就是你输入的那个程序。在远程机器运行 `git`、`python` 或部署脚本之前，SSH 守护进程会选择一个账户，调用该账户的 shell，再把命令字符串交给这个 shell。启动文件和服务器规则可能修改环境、替换命令，或者让一次调用进入交互模式，而另一次保持非交互模式。

当代理负责报告结果时，这一点更加重要。人看到意外的横幅、别名或彩色提示符，可能会停下来调查。代理则可能把退出码 0 和一行看起来熟悉的输出当成目标程序确实运行的证据。我见过有人因为这个假设，把一次无害的状态检查变成了对包装脚本的调用，也见过部署检查返回了错误可执行文件的结果。

解决办法不是删除所有配置文件。人们需要可用的交互式 shell。真正需要做的是确认执行路径，把人的便利设置与自动化设置分开，并明确远程命令契约。

## 远程命令首先会经过账户 shell

OpenSSH 通常不会把 `ssh host` 后面的文字直接通过 `execve` 执行为目标二进制文件。`sshd` 手册说明，完成身份验证后，sshd 会使用用户的 shell，并通过 shell 的 `-c` 选项运行请求的命令。登录 shell 的路径来自账户数据库，而不是发起 SSH 连接的本地机器。

这个细节可以解释许多令人困惑的报告。假设代理发送：

```text
ssh deploy@buildbox git -C /srv/app rev-parse HEAD
```

远程账户可能使用 Bash、zsh、fish、受限 shell 或站点包装器。选中的 shell 会收到类似 `git -C /srv/app rev-parse HEAD` 的命令。它可以先初始化自身，也可能在执行到这一步之前就从 sshd 收到替代命令。

这和代理在本地使用的 shell 是两回事。代理可能在 Mac 上一个干净的进程中运行，而远程的 `deploy` 账户可能积累了十年的个人 shell 定制。本地提示符很干净，并不意味着远端也干净。

也不要把 shell 的命令解析和 SSH 传输混为一谈。SSH 会加密连接并验证账户身份，但它不会保证 `python` 就是你预期的二进制文件，不会保证 PATH 没有变化，也不会保证配置文件没有向标准输出打印文字。

所以，第一个实际问题不是“SSH 连接成功了吗？”而是“哪个程序以哪个账户、按照哪些初始化规则解释了远程命令？”

## Shell 模式决定哪些文件会生效

Shell 的启动行为取决于 shell 家族和调用模式。人们随口说“SSH shell”或“Bash shell”，这些说法并不足以预测实际行为。

对于 Bash，GNU Bash Reference Manual 区分了登录、交互式和非交互式调用。登录 Bash shell 会读取 `/etc/profile`，然后读取 `~/.bash_profile`、`~/.bash_login` 和 `~/.profile` 中第一个可读的个人文件。非登录的交互式 shell 会读取 `~/.bashrc`。

普通远程命令通常运行在非交互式 shell 中，但这不代表它什么都不加载。如果设置了 `BASH_ENV`，Bash 会在执行非交互式命令前读取该变量指定的文件。Bash 手册还说明，当 Bash 检测到 sshd 启动它时，其标准输入连接到了网络，它可能会读取 `~/.bashrc`。不要根据“.bashrc 只影响交互式使用”这种经验规则来设计自动化。

zsh 的规则不同。`/etc/zshenv` 和 `~/.zshenv` 会影响每次 zsh 调用，因此一个包含输出或状态操作的 `.zshenv` 特别危险。zsh 会为登录 shell 读取 `.zprofile`，为交互式 shell 读取 `.zshrc`。如果用户把 PATH 修改和状态输出放进 `.zshenv`，远程命令、脚本以及许多图形应用的启动都会受到影响。

POSIX `sh` 没有一个通用的绕过方法。不同实现之间存在差异。POSIX shell 规范描述了交互式 shell 的 `ENV` 文件，但系统中的 `/bin/sh` 可能是 dash、兼容模式下的 Bash，或其他具有自身细节的实现。应当测试实际主机，而不是假设同一个文件名在所有地方都代表相同的行为。

这种区别会带来明确的运维后果。交互式排障会话中成功的命令，可能因为命令会话跳过了某个配置文件，而在代理运行时失败。反过来也可能发生：自动化钩子影响命令会话，而人的终端没有受到影响。

把交互式设置放在只有交互式 shell 会读取的文件中。提示符、补全设置、终端标题、颜色默认值和问候语都应放在那里。把自动化所需的环境放进一个小型且有文档记录的文件，由受控启动器主动加载。不要让代理通过继承开发者的点文件来发现自己的运行环境。

## PATH 修改改变的是程序身份，而不只是便利性

PATH 常被当成便利设置。在无人值守执行中，它决定程序身份。如果配置文件把 `/opt/team/bin` 放到 PATH 前面，那么 `curl`、`git`、`ssh` 或 `python` 可能指向包装器，而不是系统程序。

这个包装器可能是有意设计的。团队会用包装器选择云凭据、强制执行仓库检查、添加遥测，或选择语言运行时。问题在于让代理自行推断未限定名称的命令就是供应商提供的可执行文件。它真正表示的只是“当前 shell 的 PATH 中第一个匹配的可执行文件”。

别名和函数也会带来类似的不确定性，不过它们的行为取决于 shell 模式。除非启用了 `expand_aliases`，否则 Bash 通常不会在非交互式 shell 中展开别名。因此，别名在标准 SSH 命令执行中不常见，但并非不可能。被加载的文件可以启用别名展开。函数则完全不需要别名展开。配置文件可以定义名为 `git` 的函数、导出环境状态，并让该 shell 后续的每条命令都调用这个函数。

可以先询问 shell，但要把答案看作当前环境中的证据，而不是普遍有效的证明：

```sh
ssh -T deploy@buildbox '\nprintf "shell argv0: %s\\n" "$0"\nprintf "shell flags: %s\\n" "$-"\nprintf "PATH: %s\\n" "$PATH"\ncommand -V git\ncommand -V python\ncommand -V curl\n'
```

典型结果可能如下：

```text
shell argv0: -bash
shell flags: hBc
PATH: /opt/team/bin:/usr/local/bin:/usr/bin:/bin
git is /opt/team/bin/git
python is /usr/local/bin/python
curl is /usr/bin/curl
```

`command -V` 内置命令可以报告别名、函数、内置命令或路径。在支持它的 shell 中，`type -a git` 可以显示多个匹配路径。在 Bash 中，如果 `git` 是函数，`declare -f git` 会打印函数体。这些检查能暴露一种常见故障：配置文件定义了一个 `git()` 函数，它运行真正的 Git 命令，然后悄悄发送状态事件。输出看起来仍然像 Git 的输出，但副作用发生在别处。

命令哈希又带来一个问题。一些 shell 会缓存命令所在的位置。如果配置文件在 shell 解析名称后修改了 PATH，shell 可能会继续使用缓存的位置，直到 `hash -r` 或等效命令清除缓存。这主要发生在长时间运行的交互式 shell 中，但保持 shell 进程存活的代理也可能遇到它。

如果命令身份会影响安全判断或部署结果，请使用绝对路径。不要仅仅因为笔记本上存在 `/usr/bin/git` 就直接写死它。应在目标操作系统上确认预期路径，记录下来，并在实际执行任务的账户下测试。如果部署有意使用版本管理器或包装器，就明确写出该包装器，并把它的契约纳入任务定义。

## 伪终端改变的不只是格式

`ssh host command` 通常不会分配伪终端。`ssh -t host command` 会强制分配一个。这个选项可能切换 shell 的启动分支，影响缓冲，让程序输出颜色代码，或要求输入，而非 TTY 运行时通常不会这样做。

许多 shell 配置文件会以这样的检查开始：

```sh
case $- in
  *i*) ;;
  *) return ;;
esac
```

除非 shell 判断自己是交互式的，否则这项检查会退出文件的其余部分。伪终端可能让 shell 在普通命令连接并非如此的情况下进入交互式状态。这样一来，命令会获得代理运行时没有的别名、补全设置、提示符辅助工具、PATH 修改和终端专用导出变量。

程序也会直接对终端做出反应。命令可能显示进度界面、分页输出、选择不同的诊断格式，或读取确认输入。一个期待单个 JSON 对象的机器解析器，可能因为配置文件在程序启动前打印了问候语，或者因为程序检测到 TTY 后输出控制字符，而解析失败。

调查不一致时，同时测试两种模式：

```sh
ssh -T deploy@buildbox 'printf "flags=%s tty=" "$-"; test -t 1 \u0026\u0026 echo yes || echo no'
ssh -tt deploy@buildbox 'printf "flags=%s tty=" "$-"; test -t 1 \u0026\u0026 echo yes || echo no'
```

第一条命令要求 SSH 不分配终端。第二条命令即使本地标准输入不是终端，也会强制分配终端。比较输出，再比较两种模式下的命令解析结果和 PATH。如果结果不同，不要先修改解析器。应先找出造成差异的启动分支。

自动化默认应使用 `-T`。只有真正需要终端的命令才分配终端，例如受控的交互式恢复任务。一个代理需要终端才能执行例行状态查询，说明它已经处在较难预测的环境中。

## 服务器规则可以替换请求的命令

启动文件只是其中一层。服务器可以在账户 shell 看到命令之前替换或限制命令。如果只检查 `.bashrc`，就可能错过真正改变结果的规则。

`sshd_config` 手册记录了 `ForceCommand`。管理员可以全局应用它，也可以在 `Match` 块中针对用户、组、地址或其他条件应用它。例如使用 `ForceCommand internal-sftp` 时，服务器会忽略普通 shell 命令，运行内部 SFTP 服务。使用自定义包装器时，包装器会收到请求命令的上下文，并决定如何处理。

SSH 公钥条目也可以在账户的 `authorized_keys` 文件中加入 `command="..."` 限制。这在备份账户、仓库访问、文件传输和范围很窄的自动化中很常见，也是良好的安全实践。但如果有人给代理一个受限凭据，却期望它能执行任意命令，这就会造成排障问题。

环境控制也应纳入检查。`AcceptEnv` 告诉 sshd 接受哪些客户端提供的变量。`SetEnv` 可以在服务器端设置变量。启用 `PermitUserEnvironment` 后，账户的 SSH 环境文件或授权密钥选项可能设置变量。这些设置会影响 PATH、区域设置、语言运行时行为，以及 `BASH_ENV` 等钩子。

可以使用 `ssh -G` 检查客户端，但要清楚它的边界：

```sh
ssh -G buildbox | grep -E '^(hostname|user|port|requesttty|remotecommand|sendenv|setenv) '
```

OpenSSH 会在应用本地 `Host` 块和默认值后打印客户端配置。输出可以显示意外的 `RemoteCommand`、强制终端设置、环境转发规则或不同的目标主机，但无法显示服务器端的 `ForceCommand`、远程账户的登录 shell，或 `authorized_keys` 中保存的限制。

如果账户用于自动化，应向服务器负责人索要相关的 `sshd_config` 和账户限制。如果你不管理这台主机，就要求一套有文档记录的执行接口，不要通过反复试运行来逆向分析某人的个人 shell 行为。一个带有不透明强制包装器的账户，即使接受身份验证，也不是通用的 SSH 端点。

## 检查执行路径，不要相信单个探针

诊断命令运行在正被怀疑的环境中。这并不意味着无法诊断，而是意味着你应收集多个相互独立的事实，并标明每项事实能证明什么。

首先使用 `ssh -G` 检查本地客户端配置。然后通过主机的账户数据库确定远程账户配置的 shell。在许多 Linux 主机上，`getent passwd deploy` 会返回一条冒号分隔的记录，最后一个字段就是 shell 路径。在 macOS 上，管理员可以使用 Directory Service 工具检查账户。条件允许时，最好通过可信的管理通道完成这些操作，而不要依赖账户本身可能已经定制过的 shell。

接着获取并检查实际 shell 的启动文件，包括全局文件、个人文件以及它们加载的文件。搜索以下类型的行为：

- `PATH=` 赋值、版本管理器初始化和 `hash` 命令
- `alias`、函数定义，以及 `expand_aliases` 等 shell 选项
- `echo`、`printf` 和终端控制辅助工具等输出命令
- `BASH_ENV`、`ENV`、区域设置变量和语言专用环境钩子
- 检查 `-t`、`$-`、`$SSH_TTY` 或 `$TERM` 的条件分支

不要只搜索单词 `alias`。例如 `. ~/.local/share/tool/init.sh` 可能会加载定义目标函数的文件。应沿着加载链一直追踪到末端。配置文件往往会逐渐变成一堆条件式包含文件，而自动化行为就是在这里变得难以审查。

然后用无害命令进行一次范围较窄的指纹检查。记录 shell 标志、当前目录、PATH、相关环境变量名称，以及任务将调用的确切工具的解析结果。不要把所有环境变量都输出到日志中，因为令牌和云凭据经常存在其中。诊断记录应证明执行条件，而不是变成新的秘密存储。

最后，在代理将使用的准确模式下，用准确的生产命令进行测试：相同账户、除非必要不使用终端、相同的输入传输方式，以及相同的工作目录。通过管理员的交互式登录得到的结果不能替代这项测试，因为它回答的是另一个问题。

把证据和部署或自动化定义放在一起。真正有用的材料不是一张能正常工作的终端截图，而是一份简短文档，其中写明账户 shell、可能影响运行的启动文件、所需的可执行文件路径、预期环境、终端策略，以及服务器端的命令限制。

## 让自动化 shell 有意保持简单

可靠的远程操作应进入一个已知 shell，使用精简环境，然后执行指定的二进制文件。这并不会消除 sshd 最初使用的账户 shell，但能限制继承状态传递到实际执行任务的程序中。

对于固定的小型脚本，可以通过标准输入发送脚本，并在清空环境的情况下运行已知 shell。远程命令字符串保持固定，也避免了本地和远程多层引号转义形成的脆弱结构。

```sh
ssh -T deploy@buildbox '/usr/bin/env -i PATH=/usr/bin:/bin /bin/sh -s' \u003c\u003c'REMOTE'
set -eu
PATH=/usr/bin:/bin
export PATH

/usr/bin/git -C /srv/app rev-parse HEAD
REMOTE
```

这种方式有几个优点。`-T` 避免终端。`/usr/bin/env -i` 清除继承的变量。`/bin/sh -s` 从标准输入读取原样脚本，而带引号的 heredoc 会阻止本地 shell 在传输前展开 `$PATH` 或其他文本。`set -eu` 会在变量未设置或简单命令失败时停止执行，但仍遵循 shell 的正常语义。

它也有局限。`/usr/bin/env`、`/bin/sh` 和 `/usr/bin/git` 只是示例，不能直接照搬到每台主机。应在每类目标主机上确认路径。清空环境可能会删除程序合理需要的变量，包括主目录、区域设置、代理配置或运行时位置。只添加程序确实需要的变量，并记录每个变量存在的原因。

不要用 `env -i` 来掩盖损坏的账户配置。如果启动文件对初始 shell 修改得过于激进，导致固定启动器都无法运行，这个账户就不适合无人值守工作。应使用专用账户、受控登录 shell 和最少的启动文件。

不要把任意代理文本直接拼进远程 `sh -c` 字符串。引号错误可能让数据在到达目标脚本前就变成 shell 语法。应通过受限通道传递数据，使用带参数校验的固定远程脚本，或使用为结构化请求设计的协议。Shell 的强大之处在于它会把文本解析为代码。代理不应意外模糊这条边界。

## 分开交互式账户和代理账户

最干净的设计是为人和自动化使用不同的远程身份。开发者账户可以保留提示符、补全、语言管理器和个人辅助工具。自动化账户则应有明确的 shell、小型主目录，以及在命令会话中什么都不做或只执行少量经审查设置的启动文件。

这种分离不是官僚做法。它让你能够回答一些平常却必要的问题：部署使用的是哪个 Git 二进制文件？起始目录是什么？账户是否接受终端？哪些环境变量可能影响发布？谁可以修改执行命令的包装器？

专用账户也让命令限制更容易理解。可以为账户允许的少数操作使用强制包装器，并拒绝其他一切操作。但不要把包装器当成策略语言。它应当简单到可以检查：验证参数、设置有文档记录的环境、调用绝对路径的程序，并写入审计记录。

不要把几十个例外塞进 `.bashrc` 的大型 `case` 语句中来解决问题。这种方法很流行，因为每个用户已经有配置文件，不需要修改部署。它的问题在于，配置文件语义会随 shell 模式变化，加载顺序会变得不透明，一次看似无害的交互式修改也可能改变无人值守行为。应把自动化设置放进拥有自动化契约的启动器或专用脚本中。

Sallyport 可以让 SSH 凭据远离代理进程，并通过内置的 `sp-ssh` 辅助工具执行 SSH 操作，但它无法让一个不受控制的远程账户变得确定。应把远程账户审查视为操作设计的一部分，再利用活动记录比较代理请求的内容和远程返回的内容。

## 明确契约后再信任结果

当你无需依赖某个人当前的 shell 习惯，就能说明到底执行了什么时，代理命令的结果才值得信任。这种说明不应只有主机名和退出码，还应标明 SSH 账户、目标主机身份、终端设置、服务器命令限制、选定的 shell、环境构建方式、脚本来源和可执行文件路径。

这里存在一个不太舒服的边界。你无法仅凭远程命令自己的输出，证明远程 shell 没有在输出之前修改命令。如果账户 shell、授权密钥限制或强制命令不在你的控制范围内，就需要管理层面的证据，或者换用另一个账户。把同一个探针重复十次，只是在重复同一个假设。

从代理已经运行的、可能修改代码、基础设施或生产数据的命令开始。对每条命令分别在有终端和无终端的情况下运行，检查可执行文件的解析结果，并在结果重要时用明确的启动器替代继承的设置。第一个意外通常是 PATH，第二个通常是某个人忘记仍在加载的启动文件。

找到这类意外后，不要再向配置文件添加一个条件就认为问题解决了。应把行为移入受控账户或固定脚本，记录命令契约，并让下一次代理运行变得足够平淡，以至于它的输出真正代表它所说的意思。
