# AI 辅助开发中的 SSH 代理转发风险

SSH 代理转发的风险很容易被低估，因为私钥仍然留在开发者的电脑上。这个事实没错，但它并不足够。收到转发代理套接字的远程主机，可以请求本地代理为 SSH 身份验证挑战签名。对于每个接受该身份的系统，只要访问仍然有效，远程主机就可能以你的身份行事。

AI 辅助开发让这种错误更容易发生，也更难被察觉。编程代理可以打开远程 shell、运行部署命令、检查代码仓库，还可以遵循你多年前为交互式会话写下的 SSH 配置。如果代理到达一台拥有你转发套接字的机器，边界就已经发生了移动。默认关闭转发；只有远程任务确实需要再经过一跳时，才为它们提供范围受限的独立身份。

## 转发的代理可以在第一台主机之外完成身份验证

SSH 代理转发暴露的是签名服务，而不是私钥副本。本地 `ssh-agent` 保存着一个或多个私有身份。启用转发建立连接时，SSH 会在远程机器上创建一个套接字，并通过加密连接把签名请求转发给本地代理。

这个区别经常导致错误的风险判断。人们听到「密钥从未离开我的笔记本」，就认为远程机器没有值得攻击的东西。私钥字节可能确实一直留在本地，但 SSH 身份验证不要求远程机器拥有这些字节，只需要对身份验证请求生成有效签名。转发的套接字为远程机器获取签名提供了途径。

OpenSSH 的 `ssh_config(5)` 手册对 `ForwardAgent` 的说明很直接，并提醒说，能够绕过远程主机文件权限的用户可以访问转发的代理。实际上，这意味着远程账户本身、以该账户运行的进程、管理员，或取得足够控制权的攻击者，都可能使用这个套接字。远程主机上的 root 权限会让套接字权限问题失去意义。

RFC 4252 将基于公钥的 SSH 身份验证描述为对连接相关数据生成签名。服务器会根据授权公钥验证该签名。被攻陷的远程主机无法把代理变成任意文档的通用签名工具，但它可以请求尝试登录其他 SSH 系统所需的签名。如果你的身份能访问代码托管、生产主机或内部管理系统，这正是攻击者想要的能力。

常见的失败路径如下：

1. 开发者使用 `ssh -A` 连接 `build.example.net`，因为这台机器必须访问私有代码仓库。
2. 开发者的本地代理通过 `SSH_AUTH_SOCK` 出现在构建主机上。
3. 构建脚本、被入侵的依赖，或拥有足够权限的其他用户请求该套接字为 `git.example.net` 或内部主机签名。
4. 目标系统接受开发者的公钥，并把新连接记录为开发者发起。

目标服务器看到的是合法的加密签名。它无法判断连接究竟是由开发者在笔记本电脑上发起，还是由第一台远程机器上的进程通过转发请求。服务器日志会记录被接受的密钥和来源地址，但无法弥补这种已经消失的区别。

## 配置继承会造成意外暴露

大多数不安全的转发并不是有意识地在高风险会话中做出的决定，而是从 SSH 配置开始的。一条旧的 `Host *` 配置，例如 `ForwardAgent yes`，可能会应用到开发者、终端工具或编程代理联系的每一台主机上。工具从脚本中调用 `ssh` 时也会生效，而此时通常没人能看到提醒。

OpenSSH 配置还有一个容易出错的地方：对于每个参数，客户端通常采用它找到的第一个值。放在前面的宽泛规则可能会覆盖你以为已经写好的例外。不要只快速查看 `~/.ssh/config` 就认为配置没问题，应让 SSH 告诉你它实际会使用什么配置。

连接前，在本地机器上运行：

```sh
ssh -G deploy.internal.example | grep '^forwardagent '
```

安全的结果是：

```text
forwardagent no
```

`ssh -G` 会在处理配置文件和命令行选项后，打印最终生效的客户端配置。它不会建立连接，因此适合用于环境检查，也适合放进读取敏感主机列表的测试脚本中。

在相关配置文件靠后的位置设置明确的默认拒绝规则。由于 OpenSSH 使用第一个匹配值，请把范围较窄的例外放在它前面：

```sshconfig
Host docs-bastion.example
    ForwardAgent yes

Host *
    ForwardAgent no
```

这个示例仍然授予了一个危险的例外，因此必须明确它的理由和负责人。重点是让例外可见，并限制在一台主机上，而不是悄悄应用到所有新环境。

临时连接时，即使配置文件写了其他设置，也可以强制使用安全选项：

```sh
ssh -o ForwardAgent=no developer@host.example
```

较短的 `ssh -a developer@host.example` 也能完成同样的操作。连接尚未审查的机器、临时支持主机、培训环境或供应商管理的系统时，可以使用其中一种方式。

不要把 `IdentitiesOnly yes` 误认为转发控制项。它影响 SSH 客户端向服务器进行身份验证时提供哪些身份，不会阻止 SSH 在远程端创建代理套接字。同样，只在某个 shell 中移除 `SSH_AUTH_SOCK` 也不是完整的策略。子进程可能继承另一套环境，而下一次连接时 SSH 配置又可能重新启用转发。

## AI 工作流会放大需要审查的路径

AI 编程代理不需要恶意意图，也能让转发变得危险。它只需要获得执行某条命令的权限，而这条命令碰巧遇到了你已有的 SSH 使用习惯。代理会遵循代码仓库中的指令、调用构建脚本、使用远程开发主机，并以小幅变化重试命令。这些行为本身都很正常。当环境把可复用的开发者身份交给代理时，它们就会变得危险。

风险路径往往是间接的。本地代理先通过 SSH 打开开发环境的会话。由于全局配置规则，这个会话转发了你的代理。编程代理在开发环境中运行代码仓库脚本。脚本获取依赖或打开第二个 SSH 连接，而第二个连接可以使用第一条会话放在那里套接字。

这比人手动输入一次 `git fetch` 更危险，因为代理可以连续执行很多工具调用，不会停下来思考为什么远程 shell 需要访问一个无关的目标。代理还可能遇到代码仓库中的指令，要求它使用某个主机别名。这个别名可能隐藏了 `ProxyCommand`、`Match` 规则，或操作者没有注意到的继承式转发规则。

请把下面几项看成彼此独立的权限：

- 允许代理打开远程 shell。
- 允许该远程 shell 访问另一个 SSH 目标。
- 允许第二个目标接受开发者的身份。
- 允许代理触发第二个连接。

团队经常把这四项合并成一句「代理需要 SSH」。这句话没有说明边界。远程 shell 和可复用的签名能力会带来不同后果，需要分别决定。

检查代理进程是如何启动的。如果它从交互式终端继承了 `SSH_AUTH_SOCK`，就可能已经能够使用本地代理中的身份直接进行 SSH 身份验证。如果它随后连接到一台启用了转发的主机，这台主机会得到另一条访问这些身份的路径。清除环境变量的包装器可以减少意外的本地使用，但不能替代安全的 SSH 配置，也不能替代用于自动化的独立身份。

还要检查代理指令和自动化包装器中是否出现 `ssh -A`、`scp -A`，或会展开为这些选项的别名。`scp` 和 `sftp` 依赖 SSH 传输设置，因此转发习惯可能比最初创建它的命令传播得更远。应把转发决定放在连接定义中，并明确设置默认值为否。

## 跳板机不需要你的代理

许多开发者启用转发，是因为必须经过跳板机才能到达内部系统。过去常见的做法是「登录跳板机，再次运行 SSH」，这时转发看起来像一个方便的解决方案。它在配置初期确实很快，但也会把跳板机变成可以复用你身份的机器。

第一台主机只需要传输流量时，请使用 `ProxyJump`。本地客户端可以在通过跳板机建立隧道的同时，向最终主机完成身份验证，而不必把代理套接字转发到跳板机。

```sshconfig
Host engineering-bastion
    HostName bastion.example
    User developer
    ForwardAgent no

Host release-host
    HostName release.internal.example
    User deploy
    ProxyJump engineering-bastion
    ForwardAgent no
```

在这种配置下，本地 SSH 客户端会通过跳板机建立最终的 SSH 连接。跳板机只传输加密流量，不会收到可以用来向你的代理请求签名的远程 `SSH_AUTH_SOCK`。

不要想当然地认为配置含义正确，应实际测试这条路径：

```sh
ssh -vvv release-host
```

在调试输出中查找代理跳转连接，并确认其中没有报告代理转发。然后登录最终主机，检查远程环境：

```sh
printf 'SSH_AUTH_SOCK=%s\\n' "${SSH_AUTH_SOCK:-}"
ssh-add -l
```

如果你有意禁用了转发，空的套接字变量就是预期结果。如果 `ssh-add -l` 报告无法连接到身份验证代理，也与没有转发代理相符。排查问题时，不要只在最终主机上检查，也要在每个可能启用了转发的交互式跳转节点上检查。

确实存在跳板机必须自行向另一台主机完成身份验证的情况，例如受控的发布操作。这意味着跳板机需要自己的部署身份或短期工作负载身份，并不意味着它应该借用开发者笔记本代理中当前加载的所有身份。

## 远程自动化需要独立的身份

远程构建机、部署主机或 AI 工具应以分配给它的工作负载身份进行身份验证，而不是以碰巧启动会话的开发者身份进行身份验证。这种设计变化可以从根本上消除转发需求，而不是只让转发变得不方便。

工作负载身份应该只授予该工作负载必须使用的服务权限。访问代码仓库时，如果服务支持，请使用限定到具体仓库的部署身份。访问 SSH 目标时，为专用账户授权专用公钥，并在服务器支持的情况下限制该账户可以执行的命令或权限。使用基于证书的 SSH 配置时，签发与工作负载角色匹配主体的短期证书。

SSH 证书可以缩小访问范围，但不要把它们当成万能方案。只有服务器正确检查主体时，证书主体才会控制哪些账户接受该证书。较短的有效期可以限制签名能够用于身份验证的时间，但在这段时间内，被攻陷的进程仍然可以使用证书。请审查信任证书颁发机构的服务器配置，以及使用这些主体的账户规则。

不要把开发者的个人公钥当作 CI worker 或远程代理的「临时」身份。这样会让审计记录含糊不清。当身份验证记录显示个人密钥登录时，你很难判断究竟是开发者本人、构建任务，还是被攻陷的远程 shell 通过转发完成了操作。

对于由代理控制的 HTTP 和 SSH 操作，可以把凭据保存在本地操作网关中，只把命令或 API 结果返回给代理。Sallyport 在 macOS 上采用这种模式：它的保险库存放 SSH 密钥，`sp-ssh` 助手负责执行 SSH 操作，而不会把凭据交给代理。

有用的边界在于实际操作，而不是口头描述。代理请求一个命名操作，网关应用相应凭据，代理收到输出。代理不会得到可以传递给无关远程进程的代理套接字。这样，审批和记录才真正有意义，因为它们对应的是一次具体操作，而不是未来随时请求签名的开放能力。

## 确认提示能减少暴露，但无法修复信任问题

有时短期维护任务确实需要转发，而此时还没有准备好独立身份。这种情况下，应尽量少转发签名权限，并让所有剩余使用都可见。这是临时控制措施，不是永久架构。

不要转发包含日常全部身份的代理，而应启动一个独立的代理套接字。只加入维护任务所需的身份，并设置较短的有效期和确认要求：

```sh
ssh-agent -a "$HOME/.ssh/maintenance-agent.sock" \u003e "$HOME/.ssh/maintenance-agent.env"
. "$HOME/.ssh/maintenance-agent.env"
ssh-add -c -t 900 ~/.ssh/maintenance_ed25519
ssh -o ForwardAgent=yes operator@maintenance.example
```

`ssh-add -c` 会在代理签名前请求确认。`-t 900` 会在 15 分钟后移除该身份。请查看你所安装的 OpenSSH 版本中的 `ssh-add(1)` 手册，因为确认行为取决于本地代理和用户界面。

为这项任务使用独立的 shell。工作结束后，移除身份并终止该代理：

```sh
ssh-add -D
ssh-agent -k
```

这组操作可以阻止后续请求通过该临时套接字发起。但它无法撤销已经发出的签名、已经完成身份验证的连接，也无法删除远程进程已经取得的数据。关闭远程会话，并检查该身份能够访问的目标。

包含相关功能的 OpenSSH 版本还支持通过 `ssh-add -h` 设置目标约束。这些约束可以根据 `known_hosts` 中的主机密钥，限制代理可以为哪些主机路径签名。在受控环境中值得评估，但它会增加团队经常无法持续维护的配置依赖。过期或不完整的 `known_hosts` 文件可能让安全措施在最糟糕的时刻变成故障来源。请使用自动化实际采用的跳转路径和主机别名进行测试。

不要把提示疲劳当作安全边界。被攻陷的主机可以反复请求签名，并使用看起来像正常基础设施的目标名称。如果操作人员为了尽快结束事件而快速批准提示，确认机制提供的保护会比他们想象的少。范围受限的身份和较短的有效期能缩小提示被批准后的影响范围。

## 日志必须区分会话和 SSH 操作

SSH 身份验证日志可以告诉你某个密钥在服务器上完成了身份验证，但很少能说明签名为何被请求，也很少能说明代理转发是否提供了这条路径。如果允许自动化远程操作，就应收集足够的信息，以还原是谁启动了代理、哪个进程获得了批准、它联系了哪个目标，以及请求执行了什么操作。

将会话级记录与操作级记录分开。会话记录回答哪个代理进程获得了访问权限，以及访问何时结束。操作记录回答哪个 SSH 命令或 API 调用在该权限下运行。把二者混成一条通用事件日志，会让调查变得缓慢，因为操作人员必须从零散片段中推断因果链。

发生可疑转发事件后，检查 SSH 服务器上的常规身份验证记录。具体位置取决于操作系统和服务配置，但常见位置包括系统日志条目和 SSH 守护进程的身份验证日志。查找被接受的公钥指纹、账户名称、来源地址和时间，并与第一台远程主机的会话历史进行比较。

不要仅凭 IP 地址就断定事情经过。转发的连接可能来自构建机、跳板机、网络地址转换网关或私有网络覆盖层。服务器只能确定 TCP 连接来自哪里，无法确定是谁在开发者电脑上发起了签名请求。

只有在系统把记录写到代理无法控制的位置时，防篡改记录才有帮助。能够编辑自身操作历史的进程，可以在任何人查看前删除可疑的第二跳记录。Sallyport 会从一个加密、哈希链式的审计日志中记录代理会话和单独调用，`sp audit verify` 可以在没有保险库密钥的情况下离线检查这条链。

无论选择什么工具，都要用一次真实的失败授权和一次真实成功的 SSH 命令测试它的记录。确认日志中包含代理运行、目标、凭据引用或指纹、结果和时间。只写着「工具已完成」的日志，无法回答身份复用相关的事件调查问题。

## 把暴露视为授权滥用，而不是自动认定密钥被盗

如果发现曾把代理转发给不受信任的主机，应按该主机可能在会话存在期间使用过你身份的情况处理。不要等到有证据证明它提取了私钥。真正重要的风险是未经授权的身份验证，而私钥可能从未离开你的机器。

首先阻止未来的使用。关闭到该主机的所有 SSH 会话，从代理中移除暴露的身份，并禁用针对该主机的转发规则。如果该身份被加载到共享代理中，不要在工作日不加判断地运行 `ssh-add -D` 后就宣布问题解决。这样可能中断合法会话，却仍然让受影响的公钥继续在各台服务器上获得授权。

然后，在该身份可以到达的目标上移除或撤销授权。对于普通的 authorized-key 配置，从不应再接受该密钥的账户中移除公钥，并在需要的地方换用新密钥。对于 SSH 证书，按照证书颁发机构和服务器流程撤销证书；如果能够确认暴露窗口可接受，也可以等待短期证书过期。对于代码仓库访问，则通过相应服务的常规控制项撤销或替换部署凭据或用户凭据。

调查一个范围明确的时间窗口：从转发开始可用，到远程会话结束或本地代理停止接受请求为止。检查目标系统的身份验证记录、可信的远程 shell 历史、任务记录和操作日志。如果怀疑远程主机已经被入侵，应在清理主机前保存日志。

最后修复允许这件事发生的路径。如果事件由全局 `ForwardAgent yes` 引起，只修改被攻陷主机的配置，下一台未知主机仍然会暴露。如果 AI 工作流继承了个人套接字，就为该工作流提供明确的连接设置和独立的工作负载身份。修复结果应该让不安全路径默认无法使用，而不是只提醒人们记住一个选项。

## 安全默认值应当经得起匆忙操作

转发之所以持续存在，是因为它能在当下减少阻力。开发者需要再经过一跳，构建任务需要获取私有代码，或代理必须在截止时间前完成任务。这些需求都是真实的，但它们不应成为把高度信任的开发者身份放到路径中每台机器上的理由。

全局设置 `ForwardAgent no`。使用 `ProxyJump` 让跳板机只承担传输功能。为远程自动化分配能够说明其权限范围的身份。无法避免临时例外时，隔离一个身份，要求确认，设置较短的过期时间，并在任务结束后将其移除。

针对团队本周使用的主机别名运行 `ssh -G`。这个小检查可以在远程进程获得本不该拥有的签名服务之前，发现那个悄无声息的配置错误。
